diff --git a/doc b/doc deleted file mode 160000 index 54a0ee139..000000000 --- a/doc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 54a0ee13964c70585c24a9b5869604373faaa926 diff --git a/static/metadata.html b/static/metadata.html index 3c95aae45..64706746e 100644 --- a/static/metadata.html +++ b/static/metadata.html @@ -29,23 +29,23 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - + - + @@ -67,11 +67,11 @@

Connect with {{.Config.Name}} elsewhere by visiting:

- + {{range .Config.SocialHandles}}
  • {{.Platform}}
  • {{end}} - + - \ No newline at end of file + diff --git a/webroot/android-icon-144x144.png b/webroot/img/favicon/android-icon-144x144.png similarity index 100% rename from webroot/android-icon-144x144.png rename to webroot/img/favicon/android-icon-144x144.png diff --git a/webroot/android-icon-192x192.png b/webroot/img/favicon/android-icon-192x192.png similarity index 100% rename from webroot/android-icon-192x192.png rename to webroot/img/favicon/android-icon-192x192.png diff --git a/webroot/android-icon-36x36.png b/webroot/img/favicon/android-icon-36x36.png similarity index 100% rename from webroot/android-icon-36x36.png rename to webroot/img/favicon/android-icon-36x36.png diff --git a/webroot/android-icon-48x48.png b/webroot/img/favicon/android-icon-48x48.png similarity index 100% rename from webroot/android-icon-48x48.png rename to webroot/img/favicon/android-icon-48x48.png diff --git a/webroot/android-icon-72x72.png b/webroot/img/favicon/android-icon-72x72.png similarity index 100% rename from webroot/android-icon-72x72.png rename to webroot/img/favicon/android-icon-72x72.png diff --git a/webroot/android-icon-96x96.png b/webroot/img/favicon/android-icon-96x96.png similarity index 100% rename from webroot/android-icon-96x96.png rename to webroot/img/favicon/android-icon-96x96.png diff --git a/webroot/apple-icon-114x114.png b/webroot/img/favicon/apple-icon-114x114.png similarity index 100% rename from webroot/apple-icon-114x114.png rename to webroot/img/favicon/apple-icon-114x114.png diff --git a/webroot/apple-icon-120x120.png b/webroot/img/favicon/apple-icon-120x120.png similarity index 100% rename from webroot/apple-icon-120x120.png rename to webroot/img/favicon/apple-icon-120x120.png diff --git a/webroot/apple-icon-144x144.png b/webroot/img/favicon/apple-icon-144x144.png similarity index 100% rename from webroot/apple-icon-144x144.png rename to webroot/img/favicon/apple-icon-144x144.png diff --git a/webroot/apple-icon-152x152.png b/webroot/img/favicon/apple-icon-152x152.png similarity index 100% rename from webroot/apple-icon-152x152.png rename to webroot/img/favicon/apple-icon-152x152.png diff --git a/webroot/apple-icon-180x180.png b/webroot/img/favicon/apple-icon-180x180.png similarity index 100% rename from webroot/apple-icon-180x180.png rename to webroot/img/favicon/apple-icon-180x180.png diff --git a/webroot/apple-icon-57x57.png b/webroot/img/favicon/apple-icon-57x57.png similarity index 100% rename from webroot/apple-icon-57x57.png rename to webroot/img/favicon/apple-icon-57x57.png diff --git a/webroot/apple-icon-60x60.png b/webroot/img/favicon/apple-icon-60x60.png similarity index 100% rename from webroot/apple-icon-60x60.png rename to webroot/img/favicon/apple-icon-60x60.png diff --git a/webroot/apple-icon-72x72.png b/webroot/img/favicon/apple-icon-72x72.png similarity index 100% rename from webroot/apple-icon-72x72.png rename to webroot/img/favicon/apple-icon-72x72.png diff --git a/webroot/apple-icon-76x76.png b/webroot/img/favicon/apple-icon-76x76.png similarity index 100% rename from webroot/apple-icon-76x76.png rename to webroot/img/favicon/apple-icon-76x76.png diff --git a/webroot/apple-icon-precomposed.png b/webroot/img/favicon/apple-icon-precomposed.png similarity index 100% rename from webroot/apple-icon-precomposed.png rename to webroot/img/favicon/apple-icon-precomposed.png diff --git a/webroot/apple-icon.png b/webroot/img/favicon/apple-icon.png similarity index 100% rename from webroot/apple-icon.png rename to webroot/img/favicon/apple-icon.png diff --git a/webroot/browserconfig.xml b/webroot/img/favicon/browserconfig.xml similarity index 100% rename from webroot/browserconfig.xml rename to webroot/img/favicon/browserconfig.xml diff --git a/webroot/favicon-16x16.png b/webroot/img/favicon/favicon-16x16.png similarity index 100% rename from webroot/favicon-16x16.png rename to webroot/img/favicon/favicon-16x16.png diff --git a/webroot/favicon-32x32.png b/webroot/img/favicon/favicon-32x32.png similarity index 100% rename from webroot/favicon-32x32.png rename to webroot/img/favicon/favicon-32x32.png diff --git a/webroot/favicon-96x96.png b/webroot/img/favicon/favicon-96x96.png similarity index 100% rename from webroot/favicon-96x96.png rename to webroot/img/favicon/favicon-96x96.png diff --git a/webroot/ms-icon-144x144.png b/webroot/img/favicon/ms-icon-144x144.png similarity index 100% rename from webroot/ms-icon-144x144.png rename to webroot/img/favicon/ms-icon-144x144.png diff --git a/webroot/ms-icon-150x150.png b/webroot/img/favicon/ms-icon-150x150.png similarity index 100% rename from webroot/ms-icon-150x150.png rename to webroot/img/favicon/ms-icon-150x150.png diff --git a/webroot/ms-icon-310x310.png b/webroot/img/favicon/ms-icon-310x310.png similarity index 100% rename from webroot/ms-icon-310x310.png rename to webroot/img/favicon/ms-icon-310x310.png diff --git a/webroot/ms-icon-70x70.png b/webroot/img/favicon/ms-icon-70x70.png similarity index 100% rename from webroot/ms-icon-70x70.png rename to webroot/img/favicon/ms-icon-70x70.png diff --git a/webroot/index-standalone-chat.html b/webroot/index-standalone-chat.html new file mode 100644 index 000000000..463eb3c1c --- /dev/null +++ b/webroot/index-standalone-chat.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + +
    + + + + diff --git a/webroot/index-video-only.html b/webroot/index-video-only.html new file mode 100644 index 000000000..748bd4bdc --- /dev/null +++ b/webroot/index-video-only.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + +
    + + + + diff --git a/webroot/index.html b/webroot/index.html index b4fb2b46b..40a1eb1fd 100644 --- a/webroot/index.html +++ b/webroot/index.html @@ -1,221 +1,74 @@ - - - + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + - - - - - - - - - - -
    -
    -
    -

    - - - - {{title}} -

    - -
    -
    -
    - - -
    - -
    - - - -
    -
    - -
    - -
    - -
    -
    - -
    + + + + + + -
    - {{ streamStatus }} - {{ viewerCount }} {{ 'viewer' | plural(viewerCount) }}. - Max {{ sessionMaxViewerCount }} {{ 'viewer' | plural(sessionMaxViewerCount) }}. - {{ overallMaxViewerCount }} overall. -
    -
    + + + + + + +
    -
    - {{streamerName}} + -
    {{extraUserContent}}
    +
    - -
    - - - - - - - - - - - - + + diff --git a/webroot/js/app-standalone-chat.js b/webroot/js/app-standalone-chat.js new file mode 100644 index 000000000..d2a2d0d0f --- /dev/null +++ b/webroot/js/app-standalone-chat.js @@ -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 + /> + ` + ); + } +} diff --git a/webroot/js/app-video-only.js b/webroot/js/app-video-only.js new file mode 100644 index 000000000..c45734ffe --- /dev/null +++ b/webroot/js/app-video-only.js @@ -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` +
    +
    + +
    + +
    + ${streamStatusMessage} + ${viewerCount} ${pluralize('viewer', viewerCount)}. + Max ${pluralize('viewer', sessionMaxViewerCount)}. + ${overallMaxViewerCount} overall. +
    +
    + `); + } +} diff --git a/webroot/js/app.js b/webroot/js/app.js index 1c5a8d9b5..29e355f28 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -1,28 +1,69 @@ -import Websocket from './websocket.js'; -import { MessagingInterface, Message } from './message.js'; -import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js'; -import { OwncastPlayer } from './player.js'; +import { h, Component } from 'https://unpkg.com/preact?module'; +import htm from 'https://unpkg.com/htm?module'; +const html = htm.bind(h); -const MESSAGE_OFFLINE = 'Stream is offline.'; -const MESSAGE_ONLINE = 'Stream is online'; +import { OwncastPlayer } from './components/player.js'; +import SocialIcon from './components/social.js'; +import UsernameForm from './components/chat/username.js'; +import Chat from './components/chat/chat.js'; +import Websocket from './utils/websocket.js'; -const TEMP_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +import { + addNewlines, + classNames, + clearLocalStorage, + debounce, + generateAvatar, + generateUsername, + getLocalStorage, + pluralize, + setLocalStorage, +} from './utils/helpers.js'; +import { + HEIGHT_SHORT_WIDE, + KEY_AVATAR, + KEY_CHAT_DISPLAYED, + KEY_USERNAME, + MESSAGE_OFFLINE, + MESSAGE_ONLINE, + TEMP_IMAGE, + TIMER_DISABLE_CHAT_AFTER_OFFLINE, + TIMER_STATUS_UPDATE, + TIMER_STREAM_DURATION_COUNTER, + URL_CONFIG, + URL_OWNCAST, + URL_STATUS, + WIDTH_SINGLE_COL, +} from './utils/constants.js'; -const URL_CONFIG = `/config`; -const URL_STATUS = `/status`; -const URL_CHAT_HISTORY = `/chat`; +export default class App extends Component { + constructor(props, context) { + super(props, context); -const TIMER_STATUS_UPDATE = 5000; // ms -const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins -const TIMER_STREAM_DURATION_COUNTER = 1000; + this.state = { + websocket: new Websocket(), + displayChat: getLocalStorage(KEY_CHAT_DISPLAYED), // chat panel state + chatEnabled: false, // chat input box state + username: getLocalStorage(KEY_USERNAME) || generateUsername(), + userAvatarImage: + getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`), -class Owncast { - constructor() { - this.player; + configData: {}, + extraUserContent: '', - this.configData; - this.vueApp; - this.messagingInterface = null; + playerActive: false, // player object is active + streamOnline: false, // stream is active/online + + // status + streamStatusMessage: MESSAGE_OFFLINE, + viewerCount: '', + sessionMaxViewerCount: '', + overallMaxViewerCount: '', + + // dom + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + }; // timers this.playerRestartTimer = null; @@ -31,67 +72,30 @@ class Owncast { this.disableChatTimer = null; this.streamDurationTimer = null; - // misc - this.streamStatus = null; + // misc dom events + this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this); + this.handleUsernameChange = this.handleUsernameChange.bind(this); + this.handleWindowResize = debounce(this.handleWindowResize.bind(this), 400); - Vue.filter('plural', pluralize); - - // bindings - this.vueAppMounted = this.vueAppMounted.bind(this); - this.setConfigData = this.setConfigData.bind(this); - this.getStreamStatus = this.getStreamStatus.bind(this); - this.getExtraUserContent = this.getExtraUserContent.bind(this); - this.updateStreamStatus = this.updateStreamStatus.bind(this); - this.handleNetworkingError = this.handleNetworkingError.bind(this); this.handleOfflineMode = this.handleOfflineMode.bind(this); this.handleOnlineMode = this.handleOnlineMode.bind(this); - this.handleNetworkingError = this.handleNetworkingError.bind(this); + this.disableChatInput = this.disableChatInput.bind(this); + + // player events this.handlePlayerReady = this.handlePlayerReady.bind(this); this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this); this.handlePlayerEnded = this.handlePlayerEnded.bind(this); this.handlePlayerError = this.handlePlayerError.bind(this); - this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this); + + // fetch events + this.getConfig = this.getConfig.bind(this); + this.getStreamStatus = this.getStreamStatus.bind(this); + this.getExtraUserContent = this.getExtraUserContent.bind(this); } - init() { - this.messagingInterface = new MessagingInterface(); - this.setupWebsocket(); - - this.vueApp = new Vue({ - el: '#app-container', - data: { - playerOn: false, - messages: [], - overallMaxViewerCount: 0, - sessionMaxViewerCount: 0, - streamStatus: MESSAGE_OFFLINE, // Default state. - viewerCount: 0, - isOnline: false, - - // from config - appVersion: '', - extraUserContent: '', - logo: TEMP_IMAGE, - logoLarge: TEMP_IMAGE, - socialHandles: [], - streamerName: '', - summary: '', - tags: [], - title: '', - }, - watch: { - messages: { - deep: true, - handler: this.messagingInterface.onReceivedMessages, - }, - }, - mounted: this.vueAppMounted, - }); - } - // do all these things after Vue.js has mounted, else we'll get weird DOM issues. - vueAppMounted() { + componentDidMount() { this.getConfig(); - this.messagingInterface.init(); + window.addEventListener('resize', this.handleWindowResize); this.player = new OwncastPlayer(); this.player.setupPlayerCallbacks({ @@ -101,50 +105,16 @@ class Owncast { onError: this.handlePlayerError, }); this.player.init(); - - this.getChatHistory(); - }; - - setConfigData(data) { - this.vueApp.appVersion = data.version; - this.vueApp.logo = data.logo.small; - this.vueApp.logoLarge = data.logo.large; - this.vueApp.socialHandles = data.socialHandles; - this.vueApp.streamerName = data.name; - this.vueApp.summary = data.summary && addNewlines(data.summary); - this.vueApp.tags = data.tags; - this.vueApp.title = data.title; - - window.document.title = data.title; - - this.getExtraUserContent(`${data.extraUserInfoFileName}`); - - this.configData = data; } - // websocket for messaging - setupWebsocket() { - this.websocket = new Websocket(); - this.websocket.addListener('rawWebsocketMessageReceived', this.receivedWebsocketMessage.bind(this)); - this.messagingInterface.send = this.websocket.send; - }; - - receivedWebsocketMessage(model) { - if (model.type === SOCKET_MESSAGE_TYPES.CHAT) { - const message = new Message(model); - this.addMessage(message); - } else if (model.type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { - this.addMessage(model); - } - } - - addMessage(message) { - const existing = this.vueApp.messages.filter(function (item) { - return item.id === message.id; - }) - if (existing.length === 0 || !existing) { - this.vueApp.messages = [...this.vueApp.messages, message]; - } + componentWillUnmount() { + // clear all the timers + clearInterval(this.playerRestartTimer); + clearInterval(this.offlineTimer); + clearInterval(this.statusTimer); + clearTimeout(this.disableChatTimer); + clearInterval(this.streamDurationTimer); + window.removeEventListener('resize', this.handleWindowResize); } // fetch /config data @@ -180,7 +150,7 @@ class Owncast { this.handleOfflineMode(); this.handleNetworkingError(`Stream status: ${error}`); }); - }; + } // fetch content.md getExtraUserContent(path) { @@ -192,141 +162,319 @@ class Owncast { return response.text(); }) .then(text => { - const descriptionHTML = new showdown.Converter().makeHtml(text); - this.vueApp.extraUserContent = descriptionHTML; + this.setState({ + extraUserContent: new showdown.Converter().makeHtml(text), + }); }) .catch(error => { this.handleNetworkingError(`Fetch extra content: ${error}`); }); - }; + } - // fetch chat history - getChatHistory() { - fetch(URL_CHAT_HISTORY) - .then(response => { - if (!response.ok) { - throw new Error(`Network response was not ok ${response.ok}`); - } - return response.json(); - }) - .then(data => { - const formattedMessages = data.map(function (message) { - return new Message(message); - }) - this.vueApp.messages = formattedMessages.concat(this.vueApp.messages); - }) - .catch(error => { - this.handleNetworkingError(`Fetch getChatHistory: ${error}`); + setConfigData(data = {}) { + const { title, extraUserInfoFileName, summary } = data; + + window.document.title = title; + if (extraUserInfoFileName) { + this.getExtraUserContent(extraUserInfoFileName); + } + + this.setState({ + configData: { + ...data, + summary: summary && addNewlines(summary), + }, }); } - // handle UI things from stream status result updateStreamStatus(status = {}) { + const { streamOnline: curStreamOnline } = this.state; + if (!status) { return; } - // update UI - this.vueApp.viewerCount = status.viewerCount; - this.vueApp.sessionMaxViewerCount = status.sessionMaxViewerCount; - this.vueApp.overallMaxViewerCount = status.overallMaxViewerCount; + const { + viewerCount, + sessionMaxViewerCount, + overallMaxViewerCount, + online, + } = status; this.lastDisconnectTime = status.lastDisconnectTime; - if (!this.streamStatus) { - // display offline mode the first time we get status, and it's offline. - if (!status.online) { - this.handleOfflineMode(); - } else { - this.handleOnlineMode(); - } - } else { - if (status.online && !this.streamStatus.online) { - // stream has just come online. - this.handleOnlineMode(); - } else if (!status.online && this.streamStatus.online) { - // stream has just flipped offline. - this.handleOfflineMode(); - } + if (status.online && !curStreamOnline) { + // stream has just come online. + this.handleOnlineMode(); + } else if (!status.online && curStreamOnline) { + // stream has just flipped offline. + this.handleOfflineMode(); } - - // keep a local copy - this.streamStatus = status; - if (status.online) { // only do this if video is paused, so no unnecessary img fetches if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) { this.player.setPoster(); } } - }; - - // update vueApp.streamStatus text when online - setCurrentStreamDuration() { - // Default to something - let streamDurationString = ''; - - if (this.streamStatus.lastConnectTime) { - const diff = (Date.now() - Date.parse(this.streamStatus.lastConnectTime)) / 1000; - streamDurationString = secondsToHMMSS(diff); - } - this.vueApp.streamStatus = `${MESSAGE_ONLINE} ${streamDurationString}.` - } - - handleNetworkingError(error) { - console.log(`>>> App Error: ${error}`) - }; - - // stop status timer and disable chat after some time. - handleOfflineMode() { - this.vueApp.isOnline = false; - clearInterval(this.streamDurationTimer); - this.vueApp.streamStatus = MESSAGE_OFFLINE; - if (this.streamStatus) { - const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime)); - const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime; - this.disableChatTimer = setTimeout(this.messagingInterface.disableChat, countdown); - } - }; - - // play video! - handleOnlineMode() { - this.vueApp.playerOn = true; - this.vueApp.isOnline = true; - this.vueApp.streamStatus = MESSAGE_ONLINE; - - this.player.startPlayer(); - clearTimeout(this.disableChatTimer); - this.disableChatTimer = null; - this.messagingInterface.enableChat(); - - this.streamDurationTimer = - setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER); + this.setState({ + viewerCount, + sessionMaxViewerCount, + overallMaxViewerCount, + streamOnline: online, + }); } // when videojs player is ready, start polling for stream handlePlayerReady() { this.getStreamStatus(); this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE); - }; - + } handlePlayerPlaying() { // do something? - }; + } // likely called some time after stream status has gone offline. // basically hide video and show underlying "poster" handlePlayerEnded() { - this.vueApp.playerOn = false; - }; + this.setState({ + playerActive: false, + }); + } handlePlayerError() { // do something? this.handleOfflineMode(); this.handlePlayerEnded(); - }; -}; + } + + // stop status timer and disable chat after some time. + handleOfflineMode() { + clearInterval(this.streamDurationTimer); + const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime)); + const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime; + this.disableChatTimer = setTimeout(this.disableChatInput, countdown); + this.setState({ + streamOnline: false, + streamStatusMessage: MESSAGE_OFFLINE, + }); + } + + // play video! + handleOnlineMode() { + this.player.startPlayer(); + clearTimeout(this.disableChatTimer); + this.disableChatTimer = null; + + this.streamDurationTimer = + setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER); + + this.setState({ + playerActive: true, + streamOnline: true, + chatEnabled: true, + streamStatusMessage: MESSAGE_ONLINE, + }); + } + + + handleUsernameChange(newName, newAvatar) { + this.setState({ + username: newName, + userAvatarImage: newAvatar, + }); + } + + handleChatPanelToggle() { + const { displayChat: curDisplayed } = this.state; + + const displayChat = !curDisplayed; + if (displayChat) { + setLocalStorage(KEY_CHAT_DISPLAYED, displayChat); + } else { + clearLocalStorage(KEY_CHAT_DISPLAYED); + } + this.setState({ + displayChat, + }); + } + + disableChatInput() { + this.setState({ + chatEnabled: false, + }); + } + + handleNetworkingError(error) { + console.log(`>>> App Error: ${error}`); + } + + handleWindowResize() { + this.setState({ + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + }); + } + + render(props, state) { + const { + chatEnabled, + configData, + displayChat, + extraUserContent, + overallMaxViewerCount, + playerActive, + sessionMaxViewerCount, + streamOnline, + streamStatusMessage, + userAvatarImage, + username, + viewerCount, + websocket, + windowHeight, + windowWidth, + } = state; + + const { + version: appVersion, + logo = {}, + socialHandles = [], + name: streamerName, + summary, + tags = [], + title, + } = configData; + const { small: smallLogo = TEMP_IMAGE, large: largeLogo = TEMP_IMAGE } = logo; + + const bgLogo = { backgroundImage: `url(${smallLogo})` }; + const bgLogoLarge = { backgroundImage: `url(${largeLogo})` }; + + const tagList = !tags.length ? + null : + tags.map((tag, index) => html` +
  • ${tag}
  • + `); + + const socialIconsList = + !socialHandles.length ? + null : + socialHandles.map((item, index) => html` +
  • + <${SocialIcon} platform=${item.platform} url=${item.url} /> +
  • + `); + + const mainClass = playerActive ? 'online' : ''; + const streamInfoClass = streamOnline ? 'online' : ''; // need? + + const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE; + const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight; + const extraAppClasses = classNames({ + 'chat': displayChat, + 'no-chat': !displayChat, + 'single-col': singleColMode, + 'bg-gray-800': singleColMode && displayChat, + 'short-wide': shortHeight, + }) + + return ( + html` +
    +
    +
    +

    + + + + ${title} +

    +
    + <${UsernameForm} + username=${username} + userAvatarImage=${userAvatarImage} + handleUsernameChange=${this.handleUsernameChange} + /> + +
    +
    +
    + +
    +
    + +
    + +
    + ${streamStatusMessage} + ${viewerCount} ${pluralize('viewer', viewerCount)}. + Max ${pluralize('viewer', sessionMaxViewerCount)}. + ${overallMaxViewerCount} overall. +
    +
    + +
    +
    +
    + +
    +
    +

    + About ${streamerName} +

    + +
    +
      + ${tagList} +
    +
    +
    +
    +
    + + + + <${Chat} + websocket=${websocket} + username=${username} + userAvatarImage=${userAvatarImage} + chatEnabled //=${chatEnabled} + /> +
    + ` + ); + } +} -export default Owncast; \ No newline at end of file diff --git a/webroot/js/chat/socketMessageTypes.js b/webroot/js/chat/socketMessageTypes.js deleted file mode 100644 index 54116e1b0..000000000 --- a/webroot/js/chat/socketMessageTypes.js +++ /dev/null @@ -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' -} \ No newline at end of file diff --git a/webroot/js/components.js b/webroot/js/components.js deleted file mode 100644 index 8b4d7aa83..000000000 --- a/webroot/js/components.js +++ /dev/null @@ -1,62 +0,0 @@ -Vue.component('owncast-footer', { - props: { - appVersion: { - type: String, - default: '0.1', - }, - }, - - template: ` - - `, -}); - - -Vue.component('stream-tags', { - props: ['tags'], - template: ` - - `, -}); - -Vue.component('user-details', { - props: ['logo', 'platforms', 'summary', 'tags'], - template: ` -
    -
    - -
    -
    -

    - About - - -

    - -
    - -
    -
    - `, -}); diff --git a/webroot/js/components/chat/chat-input.js b/webroot/js/components/chat/chat-input.js new file mode 100644 index 000000000..99bbe83bd --- /dev/null +++ b/webroot/js/components/chat/chat-input.js @@ -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 = "\"""; + } 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` +
    + + <${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} + /> + +
    + ${inputWarning} + +
    + + + +
    +
    +
    + `); + } + + } diff --git a/webroot/js/components/chat/chat.js b/webroot/js/components/chat/chat.js new file mode 100644 index 000000000..37b60cec5 --- /dev/null +++ b/webroot/js/components/chat/chat.js @@ -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` +
    + ${messageList} +
    + `); + } + + return ( + html` +
    +
    +
    + ${messageList} +
    + <${ChatInput} + chatUserNames=${chatUserNames} + inputEnabled=${chatEnabled && inputEnabled} + handleSendMessage=${this.submitChat} + /> +
    +
    + `); + } + +} + diff --git a/webroot/js/components/chat/content-editable.js b/webroot/js/components/chat/content-editable.js new file mode 100644 index 000000000..c51dfd17d --- /dev/null +++ b/webroot/js/components/chat/content-editable.js @@ -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, + ); + } +} diff --git a/webroot/js/components/chat/message.js b/webroot/js/components/chat/message.js new file mode 100644 index 000000000..11c4506ba --- /dev/null +++ b/webroot/js/components/chat/message.js @@ -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` +
    +
    + +
    +
    +
    + ${author} +
    +
    +
    +
    + `); + } else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { + const { oldName, newName, image } = message; + return ( + html` +
    +
    +
    + +
    + +
    + ${oldName} is now known as ${newName}. +
    +
    +
    + ` + ); + } + } +} diff --git a/webroot/js/components/chat/username.js b/webroot/js/components/chat/username.js new file mode 100644 index 000000000..a2ba51711 --- /dev/null +++ b/webroot/js/components/chat/username.js @@ -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` +
    +
    + + ${username} +
    + +
    + + + + +
    +
    + `); + } +} diff --git a/webroot/js/player.js b/webroot/js/components/player.js similarity index 96% rename from webroot/js/player.js rename to webroot/js/components/player.js index 337617c93..8de03fcf6 100644 --- a/webroot/js/player.js +++ b/webroot/js/components/player.js @@ -17,7 +17,6 @@ const VIDEO_OPTIONS = { vhs: { // used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default. enableLowInitialPlaylist: true, - } }, liveTracker: { @@ -26,6 +25,8 @@ const VIDEO_OPTIONS = { sources: [VIDEO_SRC], }; +export const POSTER_DEFAULT = `/img/logo.png`; +export const POSTER_THUMB = `/thumbnail.jpg`; class OwncastPlayer { constructor() { @@ -125,27 +126,25 @@ class OwncastPlayer { if (window.WebKitPlaybackTargetAvailabilityEvent) { var videoJsButtonClass = videojs.getComponent('Button'); var concreteButtonClass = videojs.extend(videoJsButtonClass, { - - // The `init()` method will also work for constructor logic here, but it is + + // The `init()` method will also work for constructor logic here, but it is // deprecated. If you provide an `init()` method, it will override the // `constructor()` method! constructor: function () { videoJsButtonClass.call(this, player); - }, // notice the comma - + }, + handleClick: function () { const videoElement = document.getElementsByTagName('video')[0]; videoElement.webkitShowPlaybackTargetPicker(); - } + }, }); - + var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(new concreteButtonClass()); concreteButtonInstance.addClass("vjs-airplay"); } - }); + }); } - - } export { OwncastPlayer }; diff --git a/webroot/js/components/social.js b/webroot/js/components/social.js new file mode 100644 index 000000000..20d69f1f7 --- /dev/null +++ b/webroot/js/components/social.js @@ -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` + + + Find me on ${name} + + `); +} diff --git a/webroot/js/emoji.js b/webroot/js/emoji.js deleted file mode 100644 index 33ef36b48..000000000 --- a/webroot/js/emoji.js +++ /dev/null @@ -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 += "\"""; - } else { - document.querySelector('#message-body-form').innerHTML += emoji.emoji; - } - }); -} diff --git a/webroot/js/message.js b/webroot/js/message.js deleted file mode 100644 index f3ca460b0..000000000 --- a/webroot/js/message.js +++ /dev/null @@ -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 = `${displayURL}`; - text = text.replace(url, link); - - if (getYoutubeIdFromURL(url)) { - if (this.isTextJustURLs(text, [url, displayURL])) { - text = ''; - } else { - text += '
    '; - } - - const youtubeID = getYoutubeIdFromURL(url); - text += getYoutubeEmbedFromID(youtubeID); - } else if (url.indexOf('instagram.com/p/') > -1) { - if (this.isTextJustURLs(text, [url, displayURL])) { - text = ''; - } else { - text += `
    `; - } - text += getInstagramEmbedFromURL(url); - } else if (isImage(url)) { - if (this.isTextJustURLs(text, [url, displayURL])) { - text = ''; - } else { - text += `
    `; - } - 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, '$&'); - } -} - - - -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 ``; -} - -function getInstagramEmbedFromURL(url) { - const urlObject = new URL(url.replace(/\/$/, "")); - urlObject.pathname += "/embed"; - return ``; -} - -function isImage(url) { - const re = /\.(jpe?g|png|gif)$/; - const isImage = re.test(url); - return isImage; -} - -function getImageForURL(url) { - return ``; -} - - -// 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); -} \ No newline at end of file diff --git a/webroot/js/social.js b/webroot/js/social.js deleted file mode 100644 index a36c822b4..000000000 --- a/webroot/js/social.js +++ /dev/null @@ -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: ` - - `, - -}); - -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: ` -
  • - - - Find me on {{platform}} - -
  • - `, -}); diff --git a/webroot/js/usercolors.js b/webroot/js/usercolors.js deleted file mode 100644 index bad61fa39..000000000 --- a/webroot/js/usercolors.js +++ /dev/null @@ -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)'; -} \ No newline at end of file diff --git a/webroot/js/utils/chat.js b/webroot/js/utils/chat.js new file mode 100644 index 000000000..4b6ff89b1 --- /dev/null +++ b/webroot/js/utils/chat.js @@ -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, '$&'); +} + +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 = `${displayURL}`; + text = text.replace(url, link); + + if (getYoutubeIdFromURL(url)) { + if (isTextJustURLs(text, [url, displayURL])) { + text = ''; + } else { + text += '
    '; + } + + const youtubeID = getYoutubeIdFromURL(url); + text += getYoutubeEmbedFromID(youtubeID); + } else if (url.indexOf('instagram.com/p/') > -1) { + if (isTextJustURLs(text, [url, displayURL])) { + text = ''; + } else { + text += `
    `; + } + text += getInstagramEmbedFromURL(url); + } else if (isImage(url)) { + if (isTextJustURLs(text, [url, displayURL])) { + text = ''; + } else { + text += `
    `; + } + 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 ` +
    + +
    `; +} + +function getInstagramEmbedFromURL(url) { + const urlObject = new URL(url.replace(/\/$/, "")); + urlObject.pathname += "/embed"; + return ``; +} + +function isImage(url) { + const re = /\.(jpe?g|png|gif)$/i; + return re.test(url); +} + +function getImageForURL(url) { + return ``; +} + +// 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; +} diff --git a/webroot/js/utils/constants.js b/webroot/js/utils/constants.js new file mode 100644 index 000000000..c83de5acd --- /dev/null +++ b/webroot/js/utils/constants.js @@ -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; diff --git a/webroot/js/utils.js b/webroot/js/utils/helpers.js similarity index 63% rename from webroot/js/utils.js rename to webroot/js/utils/helpers.js index d04c17117..06aefecb8 100644 --- a/webroot/js/utils.js +++ b/webroot/js/utils/helpers.js @@ -1,16 +1,4 @@ - -const URL_STATUS = `/status`; -const URL_CHAT_HISTORY = `/chat`; -// TODO: This directory is customizable in the config. So we should expose this via the config API. -const URL_STREAM = `/hls/stream.m3u8`; -const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`; - -const POSTER_DEFAULT = `/img/logo.png`; -const POSTER_THUMB = `/thumbnail.jpg`; - -const URL_OWNCAST = 'https://github.com/gabek/owncast'; // used in footer - -function getLocalStorage(key) { +export function getLocalStorage(key) { try { return localStorage.getItem(key); } catch (e) { @@ -18,7 +6,7 @@ function getLocalStorage(key) { return null; } -function setLocalStorage(key, value) { +export function setLocalStorage(key, value) { try { if (value !== "" && value !== null) { localStorage.setItem(key, value); @@ -30,12 +18,12 @@ function setLocalStorage(key, value) { return false; } -function clearLocalStorage(key) { +export function clearLocalStorage(key) { localStorage.removeItem(key); } // jump down to the max height of a div, with a slight delay -function jumpToBottom(element) { +export function jumpToBottom(element) { if (!element) return; setTimeout(() => { @@ -48,11 +36,11 @@ function jumpToBottom(element) { } // convert newlines to
    s -function addNewlines(str) { +export function addNewlines(str) { return str.replace(/(?:\r\n|\r|\n)/g, '
    '); } -function pluralize(string, count) { +export function pluralize(string, count) { if (count === 1) { return string; } else { @@ -63,45 +51,45 @@ function pluralize(string, count) { // Trying to determine if browser is mobile/tablet. // Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent -function hasTouchScreen() { - var hasTouchScreen = false; - if ("maxTouchPoints" in navigator) { - hasTouchScreen = navigator.maxTouchPoints > 0; +export function hasTouchScreen() { + let hasTouch = false; + if ("maxTouchPoints" in navigator) { + hasTouch = navigator.maxTouchPoints > 0; } else if ("msMaxTouchPoints" in navigator) { - hasTouchScreen = navigator.msMaxTouchPoints > 0; + hasTouch = navigator.msMaxTouchPoints > 0; } else { var mQ = window.matchMedia && matchMedia("(pointer:coarse)"); if (mQ && mQ.media === "(pointer:coarse)") { - hasTouchScreen = !!mQ.matches; + hasTouch = !!mQ.matches; } else if ('orientation' in window) { - hasTouchScreen = true; // deprecated, but good fallback + hasTouch = true; // deprecated, but good fallback } else { // Only as a last resort, fall back to user agent sniffing var UA = navigator.userAgent; - hasTouchScreen = ( + hasTouch = ( /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) || /\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA) ); } } - return hasTouchScreen; + return hasTouch; } // generate random avatar from https://robohash.org -function generateAvatar(hash) { +export function generateAvatar(hash) { const avatarSource = 'https://robohash.org/'; const optionSize = '?size=80x80'; - const optionSet = '&set=set3'; + const optionSet = '&set=set2'; const optionBg = ''; // or &bgset=bg1 or bg2 return avatarSource + hash + optionSize + optionSet + optionBg; } -function generateUsername() { +export function generateUsername() { return `User ${(Math.floor(Math.random() * 42) + 1)}`; } -function secondsToHMMSS(seconds = 0) { +export function secondsToHMMSS(seconds = 0) { const finiteSeconds = Number.isFinite(+seconds) ? Math.abs(seconds) : 0; const hours = Math.floor(finiteSeconds / 3600); @@ -116,13 +104,41 @@ function secondsToHMMSS(seconds = 0) { return hoursString + minString + secsString; } -function setVHvar() { +export function setVHvar() { var vh = window.innerHeight * 0.01; // Then we set the value in the --vh custom property to the root of the document document.documentElement.style.setProperty('--vh', `${vh}px`); console.log("== new vh", vh) } -function doesObjectSupportFunction(object, functionName) { +export function doesObjectSupportFunction(object, functionName) { return typeof object[functionName] === "function"; -} \ No newline at end of file +} + +// return a string of css classes +export function classNames(json) { + const classes = []; + + Object.entries(json).map(function(item) { + const [ key, value ] = item; + if (value) { + classes.push(key); + } + return null; + }); + return classes.join(' '); +} + + +// taken from +// https://medium.com/@TCAS3/debounce-deep-dive-javascript-es6-e6f8d983b7a1 +export function debounce(fn, time) { + let timeout; + + return function() { + const functionCall = () => fn.apply(this, arguments); + + clearTimeout(timeout); + timeout = setTimeout(functionCall, time); + } +} diff --git a/webroot/js/utils/social.js b/webroot/js/utils/social.js new file mode 100644 index 000000000..9f42063e5 --- /dev/null +++ b/webroot/js/utils/social.js @@ -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], + }, +}; diff --git a/webroot/js/utils/user-colors.js b/webroot/js/utils/user-colors.js new file mode 100644 index 000000000..9dac505c4 --- /dev/null +++ b/webroot/js/utils/user-colors.js @@ -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})`; +} diff --git a/webroot/js/websocket.js b/webroot/js/utils/websocket.js similarity index 90% rename from webroot/js/websocket.js rename to webroot/js/utils/websocket.js index 6c628d6e5..bc392faa6 100644 --- a/webroot/js/websocket.js +++ b/webroot/js/utils/websocket.js @@ -1,17 +1,25 @@ -import SOCKET_MESSAGE_TYPES from './chat/socketMessageTypes.js'; +/** + * These are the types of messages that we can handle with the websocket. + * Mostly used by `websocket.js` but if other components need to handle + * different types then it can import this file. + */ +export const SOCKET_MESSAGE_TYPES = { + CHAT: 'CHAT', + PING: 'PING', + NAME_CHANGE: 'NAME_CHANGE', + PONG: 'PONG' +}; -const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`; - -const TIMER_WEBSOCKET_RECONNECT = 5000; // ms - -const CALLBACKS = { +export const CALLBACKS = { RAW_WEBSOCKET_MESSAGE_RECEIVED: 'rawWebsocketMessageReceived', WEBSOCKET_CONNECTED: 'websocketConnected', WEBSOCKET_DISCONNECTED: 'websocketDisconnected', } -class Websocket { +const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/entry`; +const TIMER_WEBSOCKET_RECONNECT = 5000; // ms +export default class Websocket { constructor() { this.websocket = null; this.websocketReconnectTimer = null; @@ -42,7 +50,7 @@ class Websocket { } } - + // Interface with other components // Outbound: Other components can pass an object to `send`. @@ -51,7 +59,7 @@ class Websocket { if (!message.type || !SOCKET_MESSAGE_TYPES[message.type]) { console.warn(`Outbound message: Unknown socket message type: "${message.type}" sent.`); } - + const messageJSON = JSON.stringify(message); this.websocket.send(messageJSON); } @@ -114,7 +122,7 @@ class Websocket { } catch (e) { console.log(e) } - + // Send PONGs if (model.type === SOCKET_MESSAGE_TYPES.PING) { this.sendPong(); @@ -133,7 +141,5 @@ class Websocket { handleNetworkingError(error) { console.error(`Websocket Error: ${error}`) - }; + } } - -export default Websocket; \ No newline at end of file diff --git a/webroot/manifest.json b/webroot/manifest.json index 013d4a6a5..b724ebe23 100644 --- a/webroot/manifest.json +++ b/webroot/manifest.json @@ -2,40 +2,40 @@ "name": "App", "icons": [ { - "src": "\/android-icon-36x36.png", + "src": "\/img\/favicon\/android-icon-36x36.png", "sizes": "36x36", "type": "image\/png", "density": "0.75" }, { - "src": "\/android-icon-48x48.png", + "src": "\/img\/favicon\/android-icon-48x48.png", "sizes": "48x48", "type": "image\/png", "density": "1.0" }, { - "src": "\/android-icon-72x72.png", + "src": "\/img\/favicon\/android-icon-72x72.png", "sizes": "72x72", "type": "image\/png", "density": "1.5" }, { - "src": "\/android-icon-96x96.png", + "src": "\/img\/favicon\/android-icon-96x96.png", "sizes": "96x96", "type": "image\/png", "density": "2.0" }, { - "src": "\/android-icon-144x144.png", + "src": "\/img\/favicon\/android-icon-144x144.png", "sizes": "144x144", "type": "image\/png", "density": "3.0" }, { - "src": "\/android-icon-192x192.png", + "src": "\/img\/favicon\/android-icon-192x192.png", "sizes": "192x192", "type": "image\/png", "density": "4.0" } ] -} \ No newline at end of file +} diff --git a/webroot/styles/app.css b/webroot/styles/app.css new file mode 100644 index 000000000..c361a19a6 --- /dev/null +++ b/webroot/styles/app.css @@ -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; + } +} */ diff --git a/webroot/styles/chat.css b/webroot/styles/chat.css new file mode 100644 index 000000000..7dcf9af46 --- /dev/null +++ b/webroot/styles/chat.css @@ -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 */ + + + + + + + + + diff --git a/webroot/styles/layout.css b/webroot/styles/layout.css index b179dfef1..3518b0705 100644 --- a/webroot/styles/layout.css +++ b/webroot/styles/layout.css @@ -1,3 +1,9 @@ +/* +Overall layout styles for all of owncast app. + +DE[RECATE THIS LAYOUT.CSS FILE. +*/ + /* variables */ :root { --header-height: 3.5em; @@ -7,25 +13,22 @@ --user-image-width: 10em; } -body { - font-size: 14px; +html { + font-size: 14px; } a:hover { text-decoration: underline; } -/* vuejs attribute to hide things before content ready */ -[v-cloak] { visibility: hidden; } ::-webkit-scrollbar { width: 0px; background: transparent; } - -.visually-hidden { +.visually-hidden { position: absolute !important; - height: 1px; + height: 1px; width: 1px; overflow: hidden; clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ @@ -33,243 +36,25 @@ a:hover { white-space: nowrap; /* added line */ } -#app-container { - width: 100%; - flex-direction: column; - justify-content: flex-start; - position: relative; -} header { - position: fixed; - width: 100%; height: var(--header-height); - top: 0; - left: 0; background-color: var(--header-bg-color); - z-index: 10; - flex-direction: row; - justify-content: space-between; - flex-wrap: nowrap; } -header h1 { - font-size: 1.25em; - font-weight: 100; - letter-spacing: 1.2; - text-transform: uppercase; - padding: .5em; - white-space: nowrap; - justify-content: flex-start; - align-items: center; - flex-direction: row; - overflow: hidden; -} -#logo-container{ - height: 1.75em; - width: 1.75em; - min-height: 1.75em; - min-width: 1.75em; - margin-right: .5em; - display: inline-block; - background-repeat: no-repeat; - background-position: center center; +#logo-container { background-size: 1.35em; } -header .instance-title { - overflow: hidden; - text-overflow: ellipsis; -} + #chat-toggle { - cursor: pointer; - text-align: center; - height: 100%; - min-width: 3em; - justify-content: center; - align-items: center; + min-width: 3rem; } -footer { - flex-direction: row; - justify-content: flex-start; - font-size: .75em; - padding: 2em; - opacity: .5; -} -footer span { - display: inline-block; - margin: 0 1em; -} - - /* ************************************************8 */ -#stream-info { - padding: .5em 2em; - text-align: center; - width: 100%; - - flex-direction: row; - justify-content: space-between; - -} -#stream-info span { - font-size: .7em; -} -.user-content { - padding: 2em; -} -/* #user-content { - display: block; -} -#user-content-touch { - display: none; -} */ -/* ************************************************8 */ - - -.user-content { - padding: 3em; - - display: flex; - flex-direction: row; -} -.user-content .user-image { - padding: 1em; - margin-right: 2em; - min-width: var(--user-image-width); - width: var(--user-image-width); - height: var(--user-image-width); - max-height: var(--user-image-width); - background-repeat: no-repeat; - background-position: center center; - background-size: calc(var(--user-image-width) - 1em); -} - -/* .user-image img { - display: inline-block; - width: 100%; - height: 100%; -} */ -.stream-summary { - margin: 1em 0; -} - -h2 { - font-size: 3em; -} -.user-content-header { - margin-bottom: 2em; -} - -.tag-list { - flex-direction: row; - margin: 1em 0; -} -.tag-list li { - font-size: .75em; - text-transform: uppercase; - margin-right: .75em; - padding: .5em; -} - - -.social-list { - flex-direction: row; - align-items: center; - justify-content: flex-start; - flex-wrap: wrap; -} -.social-list .follow-label { - font-weight: bold; - font-size: .75em; - margin-right: .5em; - text-transform: uppercase; -} - -.user-social-item { - display: flex; - justify-content: flex-start; - align-items: center; - margin-right: -.25em; -} -.user-social-item .platform-icon { - --icon-width: 40px; - height: var(--icon-width); - width: var(--icon-width); - background-image: url(../img/social-icons.gif); - background-repeat: no-repeat; - background-position: calc(var(--imgCol) * var(--icon-width)) calc(var(--imgRow) * var(--icon-width)); - transform: scale(.65); -} - -.user-social-item.use-default .platform-label { - font-size: .7em; - text-transform: uppercase; - display: inline-block; - max-width: 10em; -} - - -/* ************************************************8 */ - -#user-options-container { - flex-direction: row; - justify-content: flex-end; - align-items: center; - flex-wrap: nowrap; -} - -#user-info-display { - display: flex; - flex-direction: row; - justify-content: flex-end; - align-items: center; - cursor: pointer; - padding: .5em 1em; - overflow: hidden; - width: 100%; -} - -#username-avatar { - height: 2.1em; - width: 2.1em; - margin-right: .5em; -} -#username-display { - font-weight: 600; - font-size: .75em; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} -#user-info-display:hover { - transition: opacity .2s; - opacity: .75; -} - #user-info-change { display: none; - justify-content: flex-end; - align-items: center; - padding: .25em; -} -#username-change-input { - font-size: .75em; -} -#button-update-username { - font-size: .65em; - text-transform: uppercase; - height: 2.5em; -} -#button-cancel-change { - cursor: pointer; - height: 2.5em; - font-size: .65em; -} -.user-btn { - margin: 0 .25em; } /* ************************************************8 */ @@ -277,20 +62,12 @@ h2 { #video-container { height: calc(var(--video-container-height)); - width: 100%; margin-top: var(--header-height); - background-position: center center; - background-repeat: no-repeat; - - background-size: 30%; + background-size: 30%; } .owncast-video-container { height: auto; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: center; } .owncast-video-container .video-js { width: 100%; @@ -304,7 +81,6 @@ h2 { } .vjs-airplay .vjs-icon-placeholder::before { - /* content: 'AP'; */ content: url("../img/airplay.png"); } @@ -323,16 +99,10 @@ h2 { /* ************************************************8 */ -.no-chat #chat-container-wrap { - display: none; -} .no-chat footer { justify-content: center; } -.chat #chat-container-wrap { - display: block; -} .chat #video-container, .chat #stream-info, @@ -340,102 +110,9 @@ h2 { width: calc(100% - var(--right-col-width)); } - -#chat-container { - position: fixed; - z-index: 9; - top: var(--header-height); - right: 0; - width: var(--right-col-width); - - height: calc(100vh - var(--header-height)); - - overflow: hidden; - display: flex; - flex-direction: column; - justify-content: flex-end; +#stream-info span { + font-size: .70rem; } -.touch-screen #chat-container { - height: calc(100vh - var(--header-height) - 3vh); -} - - -#messages-container { - overflow: auto; - padding: 1em 0; -} -#message-input-container { - width: 100%; - padding: 1em; -} - -#message-form { - flex-direction: column; - align-items: flex-end; - margin-bottom: 0; -} -#message-body-form { - font-size: 1em; - height: 60px; -} -#message-body-form:disabled{ - opacity: .5; -} -#message-body-form img { - display: inline; - padding-left: 5px; - padding-right: 5px; -} - -#message-body-form .emoji { - width: 40px; -} - -#message-form-actions { - flex-direction: row; - justify-content: space-between; - align-items: center; - width: 100%; -} - -.message-text img { - display: inline; - padding-left: 5px; - padding-right: 5px; -} - -.message-text .emoji { - width: 60px; -} - -/* ************************************************8 */ - -.message { - padding: .85em; - align-items: flex-start; -} -.message-avatar { - margin-right: .75em; -} -.message-avatar img { - max-width: unset; - height: 3.0em; - width: 3.0em; - padding: 5px; -} - -.message-content { - font-size: .85em; - max-width: 85%; - word-wrap: break-word; -} -.message-content a { - color: #7F9CF5; /* indigo-400 */ -} -.message-content a:hover { - text-decoration: underline; -} - /* ************************************************8 */ @@ -452,7 +129,7 @@ h2 { --right-col-width: 20em; --user-image-width: 6em; } - + #chat-container { width: var(--right-col-width); } @@ -504,21 +181,6 @@ h2 { } } -/* try not making the video fixed position for now */ -@media (min-height: 861px) { - /* main { - position: fixed; - z-index: 9; - width: 100%; - } - #user-content { - margin-top: calc(var(--video-container-height) + var(--header-height) + 2em) - } */ -} - - - - @@ -530,194 +192,3 @@ h2 { flex-direction: column; } } - -.extra-user-content { - padding: 1em 3em 3em 3em; -} - -.extra-user-content ol { - list-style: decimal; -} - -.extra-user-content ul { - list-style: unset; -} - -.extra-user-content h1, .extra-user-content h2, .extra-user-content h3, .extra-user-content h4 { - color: #111111; - font-weight: 400; } - -.extra-user-content h1, .extra-user-content h2, .extra-user-content h3, .extra-user-content h4, .extra-user-content h5, .extra-user-content p { - margin-bottom: 24px; - padding: 0; } - -.extra-user-content h1 { - font-size: 48px; } - -.extra-user-content h2 { - font-size: 36px; - margin: 24px 0 6px; } - -.extra-user-content h3 { - font-size: 24px; } - -.extra-user-content h4 { - font-size: 21px; } - -.extra-user-content h5 { - font-size: 18px; } - -.extra-user-content a { - color: #0099ff; - margin: 0; - padding: 0; - vertical-align: baseline; } - -.extra-user-content ul, .extra-user-content ol { - padding: 0; - margin: 0; } - -.extra-user-content li { - line-height: 24px; } - -.extra-user-content li ul, .extra-user-content li ul { - margin-left: 24px; } - -.extra-user-content p, .extra-user-content ul, .extra-user-content ol { - font-size: 16px; - line-height: 24px; - } - -.extra-user-content pre { - padding: 0px 24px; - max-width: 800px; - white-space: pre-wrap; } - -.extra-user-content code { - font-family: Consolas, Monaco, Andale Mono, monospace; - line-height: 1.5; - font-size: 13px; } - -.extra-user-content aside { - display: block; - float: right; - width: 390px; } - -.extra-user-content blockquote { - margin: 1em 2em; - max-width: 476px; } - -.extra-user-content blockquote p { - color: #666; - max-width: 460px; } - -.extra-user-content hr { - width: 540px; - text-align: left; - margin: 0 auto 0 0; - color: #999; } - -.extra-user-content table { - border-collapse: collapse; - margin: 1em 1em; - border: 1px solid #CCC; } - -.extra-user-content table thead { - background-color: #EEE; } - -.extra-user-content table thead td { - color: #666; } - -.extra-user-content table td { - padding: 0.5em 1em; - border: 1px solid #CCC; } - -.message-text iframe { - width: 100%; - height: 170px; - border-radius: 15px; -} - -.message-text .instagram-embed { - height: 314px; -} - -.message-text code { - background-color:darkslategrey; - padding: 3px; -} -/* Emoji picker */ -#emoji-button { - position: relative; - top: -65px; - right: 10px; - cursor: pointer; -} - -.message-text .embedded-image { - width: 100%; - height: 170px; - border-radius: 15px; -} - -.message-text code { - background-color:darkslategrey; - padding: 3px; -} - -/* Emoji picker */ -#emoji-button { - position: relative; - top: -65px; - right: 10px; - cursor: pointer; -} -.message-text .embedded-image { - width: 100%; - height: 170px; - border-radius: 15px; -} - -.message-text code { - background-color:darkslategrey; - padding: 3px; -} -.message-text .highlighted { - color: orange; - font-weight: 400; - font-size: 14px; - -} - -.message-text code { - background-color:darkslategrey; - padding: 3px; -} - -/* -The chat input has a fake placeholder that is styled below. -It pulls the placeholder text from the div's placeholder attribute. -But really it's just the innerHTML content. -*/ - -/* If the div is empty then show the placeholder */ -#message-body-form:empty:before{ - content: attr(placeholder); - pointer-events: none; - display: block; /* For Firefox */ - - /* Style the div's placeholder text color */ - color: rgba(0, 0, 0, 0.5); -} - -/* When chat is enabled (contenteditable=true) */ -#message-body-form[contenteditable=true]:before { - opacity: 1.0; -} - - -/* When chat is disabled (contenteditable=false) chat input div should appear disabled. */ -#message-body-form[contenteditable=false] { - opacity: 0.6; -} - diff --git a/webroot/styles/standalone-chat.css b/webroot/styles/standalone-chat.css new file mode 100644 index 000000000..3354e7782 --- /dev/null +++ b/webroot/styles/standalone-chat.css @@ -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); +} diff --git a/webroot/styles/user-content.css b/webroot/styles/user-content.css new file mode 100644 index 000000000..0121ab5ea --- /dev/null +++ b/webroot/styles/user-content.css @@ -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; +} diff --git a/webroot/styles/video-only.css b/webroot/styles/video-only.css new file mode 100644 index 000000000..76fa95309 --- /dev/null +++ b/webroot/styles/video-only.css @@ -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; +} diff --git a/webroot/styles/video.css b/webroot/styles/video.css new file mode 100644 index 000000000..ca0f7731b --- /dev/null +++ b/webroot/styles/video.css @@ -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; + +*/