Custom thumbnail poster component (#281)

* Custom thumbnail poster component

* add opacity transition to thumbnail img

* fix some videoonly styles

* move video styles to video.css

* make component out of image layers; put inline styles into css

* cleanup

* update videoonly ; don't render poster if video player, remove dom modification in player

* revert interval

Co-authored-by: Ginger Wong <omqmail@gmail.com>
This commit is contained in:
Gabe Kangas 2020-10-22 14:14:44 -07:00 committed by GitHub
parent 2f37df61f2
commit 2839a5e236
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 200 additions and 71 deletions

View file

@ -2,6 +2,7 @@ import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import VideoPoster from './components/video-poster.js';
import { OwncastPlayer } from './components/player.js';
import {
@ -28,6 +29,8 @@ export default class VideoOnly extends Component {
playerActive: false, // player object is active
streamOnline: false, // stream is active/online
isPlaying: false,
//status
streamStatusMessage: MESSAGE_OFFLINE,
viewerCount: '',
@ -141,12 +144,6 @@ export default class VideoOnly extends Component {
// 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,
streamOnline: online,
@ -160,7 +157,9 @@ export default class VideoOnly extends Component {
}
handlePlayerPlaying() {
// do something?
this.setState({
isPlaying: true,
});
}
// likely called some time after stream status has gone offline.
@ -168,6 +167,7 @@ export default class VideoOnly extends Component {
handlePlayerEnded() {
this.setState({
playerActive: false,
isPlaying: false,
});
}
@ -212,29 +212,26 @@ export default class VideoOnly extends Component {
playerActive,
streamOnline,
streamStatusMessage,
isPlaying,
} = 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 { large: largeLogo = TEMP_IMAGE } = logo;
const streamInfoClass = streamOnline ? 'online' : ''; // need?
const mainClass = playerActive ? 'online' : '';
const poster = isPlaying ? null : html`
<${VideoPoster} offlineImage=${largeLogo} active=${streamOnline} />
`;
return (
html`
<main class=${mainClass}>
<div
id="video-container"
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
style=${bgLogoLarge}
>
<video
class="video-js vjs-big-play-centered display-block w-full h-full"
@ -243,9 +240,14 @@ export default class VideoOnly extends Component {
controls
playsinline
></video>
${poster}
</div>
<section id="stream-info" aria-label="Stream status" class="flex text-center flex-row justify-between items-center font-mono py-2 px-8 bg-gray-900 text-indigo-200">
<section
id="stream-info"
aria-label="Stream status"
class="flex text-center flex-row justify-between font-mono py-2 px-8 bg-gray-900 text-indigo-200 shadow-md border-b border-gray-100 border-solid ${streamInfoClass}"
>
<span>${streamStatusMessage}</span>
<span>${viewerCount} ${pluralize('viewer', viewerCount)}.</span>
</section>

View file

@ -5,6 +5,7 @@ const html = htm.bind(h);
import { OwncastPlayer } from './components/player.js';
import SocialIconsList from './components/social-icons-list.js';
import UsernameForm from './components/chat/username.js';
import VideoPoster from './components/video-poster.js';
import Chat from './components/chat/chat.js';
import Websocket from './utils/websocket.js';
import { secondsToHMMSS, hasTouchScreen, getOrientation } from './utils/helpers.js';
@ -54,6 +55,7 @@ export default class App extends Component {
playerActive: false, // player object is active
streamOnline: false, // stream is active/online
isPlaying: false, // player is actively playing video
// status
streamStatusMessage: MESSAGE_OFFLINE,
@ -191,12 +193,7 @@ export default class App extends Component {
// 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,
lastConnectTime,
@ -211,7 +208,9 @@ export default class App extends Component {
}
handlePlayerPlaying() {
// do something?
this.setState({
isPlaying: true,
});
}
// likely called some time after stream status has gone offline.
@ -219,6 +218,7 @@ export default class App extends Component {
handlePlayerEnded() {
this.setState({
playerActive: false,
isPlaying: false,
});
}
@ -320,6 +320,7 @@ export default class App extends Component {
chatInputEnabled,
configData,
displayChat,
isPlaying,
orientation,
playerActive,
streamOnline,
@ -368,7 +369,6 @@ export default class App extends Component {
const mainClass = playerActive ? 'online' : '';
const streamInfoClass = streamOnline ? 'online' : ''; // need?
const isPortrait = this.hasTouchScreen && orientation === ORIENTATION_PORTRAIT;
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait;
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
@ -382,6 +382,10 @@ export default class App extends Component {
'touch-screen': this.hasTouchScreen,
});
const poster = isPlaying ? null : html`
<${VideoPoster} offlineImage=${largeLogo} active=${streamOnline} />
`;
return html`
<div
id="app-container"
@ -429,7 +433,6 @@ export default class App extends Component {
<div
id="video-container"
class="flex owncast-video-container bg-black w-full bg-center bg-no-repeat flex flex-col items-center justify-start"
style=${bgLogoLarge}
>
<video
class="video-js vjs-big-play-centered display-block w-full h-full"
@ -438,6 +441,7 @@ export default class App extends Component {
controls
playsinline
></video>
${poster}
</div>
<section

View file

@ -118,7 +118,6 @@ class OwncastPlayer {
if (this.appPlayerEndedCallback) {
this.appPlayerEndedCallback();
}
this.setPoster();
}
handleError(e) {
@ -128,13 +127,6 @@ class OwncastPlayer {
}
}
setPoster() {
const cachebuster = Math.round(new Date().getTime() / 1000);
const poster = POSTER_THUMB + '?okhi=' + cachebuster;
this.vjsPlayer.poster(poster);
}
log(message) {
// console.log(`>>> Player: ${message}`);
}

View file

@ -0,0 +1,114 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import { TEMP_IMAGE } from '../utils/constants.js';
const REFRESH_INTERVAL = 15000;
const POSTER_BASE_URL = '/thumbnail.jpg';
export default class VideoPoster extends Component {
constructor(props) {
super(props);
this.state = {
// flipped is the state of showing primary/secondary image views
flipped: false,
oldUrl: TEMP_IMAGE,
url: TEMP_IMAGE,
};
this.refreshTimer = null;
this.startRefreshTimer = this.startRefreshTimer.bind(this);
this.fire = this.fire.bind(this);
this.setLoaded = this.setLoaded.bind(this);
}
componentDidMount() {
if (this.props.active) {
this.fire();
this.startRefreshTimer();
}
}
shouldComponentUpdate(prevProps, prevState) {
return this.props.active !== prevProps.active ||
this.props.offlineImage !== prevProps.offlineImage ||
this.state.url !== prevState.url ||
this.state.oldUrl !== prevState.oldUrl;
}
componentDidUpdate(prevProps) {
const { active } = this.props;
const { active: prevActive } = prevProps;
if (active && !prevActive) {
this.startRefreshTimer();
} else if (!active && prevActive) {
this.stopRefreshTimer();
}
}
componentWillUnmount() {
this.stopRefreshTimer();
}
startRefreshTimer() {
this.stopRefreshTimer();
this.fire();
// Load a new copy of the image every n seconds
this.refreshTimer = setInterval(this.fire, REFRESH_INTERVAL);
}
// load new img
fire() {
const cachebuster = Math.round(new Date().getTime() / 1000);
this.loadingImage = POSTER_BASE_URL + '?cb=' + cachebuster;
const img = new Image();
img.onload = this.setLoaded;
img.src = this.loadingImage;
}
setLoaded() {
const { url: currentUrl, flipped } = this.state;
this.setState({
flipped: !flipped,
url: this.loadingImage,
oldUrl: currentUrl,
});
}
stopRefreshTimer() {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
render() {
const { active, offlineImage } = this.props;
const { url, oldUrl, flipped } = this.state;
if (!active) {
return html`
<div id="oc-custom-poster">
<${ThumbImage} url=${offlineImage} visible=${true} />
</div>
`;
}
return html`
<div id="oc-custom-poster">
<${ThumbImage} url=${!flipped ? oldUrl : url } visible=${true} />
<${ThumbImage} url=${flipped ? oldUrl : url } visible=${!flipped} />
</div>
`;
}
}
function ThumbImage({ url, visible }) {
if (!url) {
return null;
}
return html`
<div
class="custom-thumbnail-image"
style=${{
opacity: visible ? 1 : 0,
backgroundImage: `url(${url})`,
}}
/>
`;
}

View file

@ -97,6 +97,10 @@ header {
pointer-events: auto;
}
#app-container .custom-thumbnail-image {
transition: opacity 2s;
}
/* *********** overrides when chat is off ***************************** */

View file

@ -37,21 +37,19 @@
/******************************/
/******************************/
#message-input img {
display: inline;
vertical-align: middle;
padding: .25rem;
padding: 0.25rem;
}
#message-input .emoji {
width: 2.2rem;
padding: .25rem;
padding: 0.25rem;
}
/* If the div is empty then show the placeholder */
#message-input:empty:before{
#message-input:empty:before {
content: attr(placeholderText);
pointer-events: none;
display: block; /* For Firefox */
@ -59,34 +57,33 @@
}
/* When chat is enabled (contenteditable=true) */
#message-input[contenteditable=true]:before {
opacity: 1.0;
#message-input[contenteditable='true']:before {
opacity: 1;
}
#message-input::selection { background:#d7ddf4; }
#message-input::selection {
background: #d7ddf4;
}
/* When chat is disabled (contenteditable=false) chat input div should appear disabled. */
#message-input:disabled,
#message-input[contenteditable=false] {
#message-input[contenteditable='false'] {
opacity: 0.6;
}
/******************************/
/******************************/
/******************************/
/* EMOJI PICKER OVERRIDES */
.emoji-picker.owncast {
--secondary-text-color: rgba(255,255,255,.5);
--category-button-color: rgba(255,255,255,.5);
--hover-color: rgba(255,255,255,.25);
--secondary-text-color: rgba(255, 255, 255, 0.5);
--category-button-color: rgba(255, 255, 255, 0.5);
--hover-color: rgba(255, 255, 255, 0.25);
background: rgba(26,32,44,1); /* tailwind bg-gray-900 */
color: rgba(226,232,240,1); /* tailwind text-gray-300 */
background: rgba(26, 32, 44, 1); /* tailwind bg-gray-900 */
color: rgba(226, 232, 240, 1); /* tailwind text-gray-300 */
border-color: black;
font-family: inherit;
}
.emoji-picker h2 {
font-family: inherit;
@ -103,7 +100,7 @@
.emoji-picker__emojis::-webkit-scrollbar-track {
border-radius: 8px;
background-color: black;
box-shadow: inset 0 0 3px rgba(0,0,0,0.3);
box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.3);
}
.emoji-picker__emojis::-webkit-scrollbar-thumb {
@ -112,7 +109,7 @@
}
.emoji-picker__emojis {
scrollbar-color: var(--category-button-color) black;
scrollbar-color: var(--category-button-color) black;
}
/* MESSAGE TEXT HTML */
@ -122,7 +119,7 @@
word-break: break-word;
}
.message-text a {
color: #7F9CF5; /* indigo-400 */
color: #7f9cf5; /* indigo-400 */
}
.message-text a:hover {
text-decoration: underline;
@ -130,34 +127,31 @@
.message-text img {
display: inline;
padding-left: 0 .25rem;
padding-left: 0 0.25rem;
}
.message-text .emoji {
position: relative;
top: -5px;
width: 3rem;
padding: .25rem
padding: 0.25rem;
}
.message-text code {
font-family: monospace;
background-color:darkslategrey;
padding: .25rem;
background-color: darkslategrey;
padding: 0.25rem;
}
.message-text .chat-embed {
width: 100%;
border-radius: .25rem;
border-radius: 0.25rem;
}
.message-text .instagram-embed {
height: 24rem;
}
.message-text .embedded-image {
width: 100%;
display: block;
@ -174,11 +168,3 @@
/* MESSAGE TEXT CONTENT */
/* MESSAGE TEXT CONTENT */

View file

@ -13,6 +13,7 @@ The styles in this file mostly ovveride those coming from chat.css
background-size: 30%;
width: 100%;
height: calc((9 / 16) * 100vw);
position: relative;
}
#video-only #video-container #video {
transition: opacity .5s;

View file

@ -14,6 +14,9 @@ video.video-js {
content: url("../img/airplay.png");
}
.vjs-big-play-button {
z-index: 100;
}
/*
videojs hack!!
@ -25,3 +28,26 @@ https://github.com/owncast/owncast/issues/165
video.vjs-tech:not([src]) {
display: none;
}
#oc-custom-poster {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
}
/* Animation time for crossfading between poster thumbs */
.custom-thumbnail-image {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
overflow: hidden;
transition: opacity 2s;
}