From c3adfe7b7b4ff9936dd10caf2493f830bad70426 Mon Sep 17 00:00:00 2001 From: Ginger Wong Date: Thu, 20 Aug 2020 15:29:15 -0700 Subject: [PATCH] preact app integration --- webroot/index2.html | 1 + webroot/js/app2.js | 306 ++++++++++++++++++++++++---------- webroot/js/chat/chat-input.js | 4 +- webroot/js/chat/standalone.js | 2 - webroot/js/chat/username.js | 49 +++--- webroot/js/player.js | 19 +-- webroot/js/social.js | 79 +++------ webroot/js/utils.js | 22 ++- 8 files changed, 296 insertions(+), 186 deletions(-) diff --git a/webroot/index2.html b/webroot/index2.html index 5ef518b28..8f2c70227 100644 --- a/webroot/index2.html +++ b/webroot/index2.html @@ -30,6 +30,7 @@ + diff --git a/webroot/js/app2.js b/webroot/js/app2.js index 8ee9c1aaf..05d672952 100644 --- a/webroot/js/app2.js +++ b/webroot/js/app2.js @@ -3,11 +3,28 @@ import htm from 'https://unpkg.com/htm?module'; const html = htm.bind(h); +import SocialIcon from './social.js'; import UsernameForm from './chat/username.js'; import Chat from './chat/chat.js'; import Websocket from './websocket.js'; - -import { getLocalStorage, generateAvatar, generateUsername, URL_OWNCAST, URL_CONFIG, URL_STATUS, addNewlines } from './utils.js'; +import { OwncastPlayer } from './player.js'; + +import { + getLocalStorage, + generateAvatar, + generateUsername, + URL_OWNCAST, + URL_CONFIG, + URL_STATUS, + addNewlines, + pluralize, + TIMER_STATUS_UPDATE, + TIMER_DISABLE_CHAT_AFTER_OFFLINE, + TIMER_STREAM_DURATION_COUNTER, + TEMP_IMAGE, + MESSAGE_OFFLINE, + MESSAGE_ONLINE, +} from './utils.js'; import { KEY_USERNAME, KEY_AVATAR, } from './utils/chat.js'; export default class App extends Component { @@ -16,13 +33,22 @@ export default class App extends Component { this.state = { websocket: new Websocket(), - chatEnabled: true, // always true for standalone chat + displayChat: false, // chat panel state + chatEnabled: false, // chat input box state username: getLocalStorage(KEY_USERNAME) || generateUsername(), userAvatarImage: getLocalStorage(KEY_AVATAR) || generateAvatar(`${this.username}${Date.now()}`), - streamStatus: null, - player: null, configData: {}, + extraUserContent: '', + + playerActive: false, // player object is active + streamOnline: false, // stream is active/online + + //status + streamStatusMessage: MESSAGE_OFFLINE, + viewerCount: '', + sessionMaxViewerCount: '', + overallMaxViewerCount: '', }; // timers @@ -32,25 +58,48 @@ export default class App extends Component { this.disableChatTimer = null; this.streamDurationTimer = null; + // misc dom events + this.handleChatPanelToggle = this.handleChatPanelToggle.bind(this); this.handleUsernameChange = this.handleUsernameChange.bind(this); + + this.handleOfflineMode = this.handleOfflineMode.bind(this); + this.handleOnlineMode = this.handleOnlineMode.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); + + // fetch events this.getConfig = this.getConfig.bind(this); this.getStreamStatus = this.getStreamStatus.bind(this); this.getExtraUserContent = this.getExtraUserContent.bind(this); + } componentDidMount() { this.getConfig(); - // DO LATER.. - // this.player = new OwncastPlayer(); - // this.player.setupPlayerCallbacks({ - // onReady: this.handlePlayerReady, - // onPlaying: this.handlePlayerPlaying, - // onEnded: this.handlePlayerEnded, - // onError: this.handlePlayerError, - // }); - // this.player.init(); + 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); + clearTimeout(this.disableChatTimer); + clearInterval(this.streamDurationTimer); } // fetch /config data @@ -98,8 +147,9 @@ export default class App extends Component { 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}`); @@ -128,66 +178,89 @@ export default class App extends Component { 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; + const { streamOnline: curStreamOnline } = this.state; 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(); } } + 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() { - this.vueApp.isOnline = false; clearInterval(this.streamDurationTimer); - this.vueApp.streamStatus = MESSAGE_OFFLINE; - if (this.streamStatus) { - const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime)); - const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime; - this.disableChatTimer = setTimeout(this.messagingInterface.disableChat, countdown); - } + const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime)); + const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime; + this.disableChatTimer = setTimeout(this.disableChatInput, countdown); + this.setState({ + streamOnline: false, + streamStatusMessage: MESSAGE_OFFLINE, + }); } // play video! handleOnlineMode() { - this.vueApp.playerOn = true; - this.vueApp.isOnline = true; - this.vueApp.streamStatus = MESSAGE_ONLINE; - this.player.startPlayer(); clearTimeout(this.disableChatTimer); this.disableChatTimer = null; - this.messagingInterface.enableChat(); this.streamDurationTimer = setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER); + + this.setState({ + playerActive: true, + streamOnline: true, + chatEnabled: true, + streamStatusMessage: MESSAGE_ONLINE, + }); } @@ -198,10 +271,16 @@ export default class App extends Component { }); } - handleChatToggle() { - const { chatEnabled: curChatEnabled } = this.state; + handleChatPanelToggle() { + const { displayChat: curDisplayed } = this.state; + this.setState({ + displayChat: !curDisplayed, + }); + } + + disableChatInput() { this.setState({ - chatEnabled: !curChatEnabled, + chatEnabled: false, }); } @@ -210,49 +289,81 @@ export default class App extends Component { } render(props, state) { - const { username, userAvatarImage, websocket, configData } = state; + const { + username, + userAvatarImage, + websocket, + configData, + extraUserContent, + displayChat, + viewerCount, + sessionMaxViewerCount, + overallMaxViewerCount, + playerActive, + streamOnline, + streamStatusMessage, + chatEnabled, + } = state; const { version: appVersion, logo = {}, - socialHandles, - name: streamnerName, + socialHandles = [], + name: streamerName, summary, - tags, + tags = [], title, } = configData; - const { small: smallLogo, large: largeLogo } = logo; + const { small: smallLogo = TEMP_IMAGE, large: largeLogo = TEMP_IMAGE } = logo; const bgLogo = { backgroundImage: `url(${smallLogo})` }; const bgLogoLarge = { backgroundImage: `url(${largeLogo})` }; - // not needed for standalone, just messages only. remove later. + 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 chatClass = displayChat ? 'chat' : 'no-chat'; + const mainClass = playerActive ? 'online' : ''; + const streamInfoClass = streamOnline ? 'online' : ''; return ( html` -
    +
    -

    +

    - + ${title}

    - - <${UsernameForm} - username=${username} - userAvatarImage=${userAvatarImage} - handleUsernameChange=${this.handleUsernameChange} - handleChatToggle=${this.handleChatToggle} - /> - +
    + <${UsernameForm} + username=${username} + userAvatarImage=${userAvatarImage} + handleUsernameChange=${this.handleUsernameChange} + /> + +
    -
    +
    -
    - {{ streamStatus }} - {{ viewerCount }} {{ 'viewer' | plural(viewerCount) }}. - Max {{ sessionMaxViewerCount }} {{ 'viewer' | plural(sessionMaxViewerCount) }}. - {{ overallMaxViewerCount }} overall. +
    + ${streamStatusMessage} + ${viewerCount} ${pluralize('viewer', viewerCount)}. + Max ${pluralize('viewer', sessionMaxViewerCount)}. + ${overallMaxViewerCount} overall.
    - {{streamerName}} - -
    {{extraUserContent}}
    +
    +
    + +
    +
    +

    + About + ${streamerName} +

    + +
    +
      + ${tagList} +
    +
    +
    +
    @@ -300,7 +434,7 @@ export default class App extends Component { websocket=${websocket} username=${username} userAvatarImage=${userAvatarImage} - chatEnabled + chatEnabled=${chatEnabled} />
    diff --git a/webroot/js/chat/chat-input.js b/webroot/js/chat/chat-input.js index 7d9fed2ca..dcc904ad5 100644 --- a/webroot/js/chat/chat-input.js +++ b/webroot/js/chat/chat-input.js @@ -240,8 +240,8 @@ export default class ChatInput extends Component { <${ContentEditable} class="appearance-none block w-full bg-gray-200 text-gray-700 border border-black-500 rounded py-2 px-2 my-2 focus:bg-white" - placeholderText=${placeholderText} + placeholderText=${placeholderText} innerRef=${this.formMessageInput} html=${inputHTML} disabled=${!inputEnabled} @@ -251,7 +251,7 @@ export default class ChatInput extends Component { onBlur=${this.handleMessageInputBlur} onPaste=${this.handlePaste} - /> + />
    ${inputWarning} diff --git a/webroot/js/chat/standalone.js b/webroot/js/chat/standalone.js index 14f0fd951..115982d63 100644 --- a/webroot/js/chat/standalone.js +++ b/webroot/js/chat/standalone.js @@ -48,7 +48,6 @@ export default class StandaloneChat extends Component { websocket=${websocket} username=${username} userAvatarImage=${userAvatarImage} - chatEnabled messagesOnly /> `); @@ -69,7 +68,6 @@ export default class StandaloneChat extends Component { websocket=${websocket} username=${username} userAvatarImage=${userAvatarImage} - chatEnabled /> `); diff --git a/webroot/js/chat/username.js b/webroot/js/chat/username.js index c18eb93de..95d82259b 100644 --- a/webroot/js/chat/username.js +++ b/webroot/js/chat/username.js @@ -59,7 +59,7 @@ export default class UsernameForm extends Component { } render(props, state) { - const { username, userAvatarImage, handleChatToggle } = props; + const { username, userAvatarImage } = props; const { displayForm } = state; const narrowSpace = document.body.clientWidth < 640; @@ -76,34 +76,31 @@ export default class UsernameForm extends Component { } return ( html` -
    -
    -
    - - ${username} -
    +
    +
    + + ${username} +
    -
    - - +
    + + - -
    +
    -
    `); } diff --git a/webroot/js/player.js b/webroot/js/player.js index 986e3566d..6e88d875e 100644 --- a/webroot/js/player.js +++ b/webroot/js/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: { @@ -128,27 +127,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 }; \ No newline at end of file +export { OwncastPlayer }; diff --git a/webroot/js/social.js b/webroot/js/social.js index a36c822b4..ec37ad558 100644 --- a/webroot/js/social.js +++ b/webroot/js/social.js @@ -1,3 +1,5 @@ +import { html } from "https://unpkg.com/htm/preact/index.mjs?module"; + const SOCIAL_PLATFORMS = { default: { name: "default", @@ -70,58 +72,31 @@ const SOCIAL_PLATFORMS = { }, }; -Vue.component('social-list', { - props: ['platforms'], - - template: ` - - `, - -}); +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; -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, + const name = inList ? platformInfo.name : platform; - 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}} + const style = `--imgRow: -${imgRow}; --imgCol: -${imgCol};`; + const itemClass = { + "user-social-item": true, + "flex": true, + "use-default": !inList, + }; + const labelClass = { + "platform-label": true, + "visually-hidden": inList, + "text-indigo-800": true, + }; + return ( + html` + + + Find me on ${name} -
  • - `, -}); + `); +} diff --git a/webroot/js/utils.js b/webroot/js/utils.js index 238bd8f58..bd9f12d08 100644 --- a/webroot/js/utils.js +++ b/webroot/js/utils.js @@ -7,6 +7,14 @@ export const URL_CONFIG = `/config`; 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 POSTER_DEFAULT = `/img/logo.png`; export const POSTER_THUMB = `/thumbnail.jpg`; @@ -66,27 +74,27 @@ export 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 export function hasTouchScreen() { - var hasTouchScreen = false; + let hasTouch = false; if ("maxTouchPoints" in navigator) { - hasTouchScreen = navigator.maxTouchPoints > 0; + 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