diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index ffa5971e7..d128a63b5 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -10,6 +10,7 @@ import { getLocalStorage, setLocalStorage } from '../../utils/helpers'; import { AppState, ChatState, + VideoState, ChatVisibilityState, getChatState, getChatVisibilityState, @@ -39,6 +40,11 @@ export const chatStateAtom = atom({ default: ChatState.Offline, }); +export const videoStateAtom = atom({ + key: 'videoStateAtom', + default: VideoState.Unavailable, +}); + export const chatVisibilityAtom = atom({ key: 'chatVisibility', default: ChatVisibilityState.Visible, @@ -71,6 +77,7 @@ export function ClientConfigStore() { const [chatMessages, setChatMessages] = useRecoilState(chatMessagesAtom); const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom); const [appState, setAppState] = useRecoilState(appStateAtom); + const [videoState, setVideoState] = useRecoilState(videoStateAtom); const [accessToken, setAccessToken] = useRecoilState(accessTokenAtom); const [websocketService, setWebsocketService] = useRecoilState(websocketServiceAtom); @@ -81,7 +88,6 @@ export function ClientConfigStore() { try { const config = await ClientConfigService.getConfig(); setClientConfig(config); - setAppState(AppState.Online); } catch (error) { console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`); } @@ -100,7 +106,6 @@ export function ClientConfigStore() { setAccessToken(newAccessToken); // setLocalStorage('accessToken', newAccessToken); setChatDisplayName(newDisplayName); - setAppState(AppState.Online); } catch (e) { console.error(`ChatService -> registerUser() ERROR: \n${e}`); } diff --git a/web/components/ui/Content/Content.tsx b/web/components/ui/Content/Content.tsx index 9ff9942da..50b1746b0 100644 --- a/web/components/ui/Content/Content.tsx +++ b/web/components/ui/Content/Content.tsx @@ -60,7 +60,7 @@ export default function ContentComponent() { return (
- + ({ + display: 'inline-block', + position: 'relative', + width, + height, + }), + [width, height], + ); + + const imgStyles = useMemo( + () => [ + { ...imgStyle, objectFit, opacity: 0, transition: `opacity ${duration}` }, + { ...imgStyle, objectFit, opacity: 1, transition: `opacity ${duration}` }, + { ...imgStyle, objectFit, opacity: 0 }, + ], + [objectFit, duration], + ); + + const [key, setKey] = useState(0); + const [srcs, setSrcs] = useState(['', '']); + const nextSrc = src !== srcs[1] ? src : ''; + + const onLoadImg = () => { + setKey((key + 1) % 3); + setSrcs([srcs[1], nextSrc]); + }; + + return ( + + {[...srcs, nextSrc].map( + (src, index) => + src !== '' && ( + + ), + )} + + ); +} + +CrossfadeImage.defaultProps = { + objectFit: 'fill', + duration: '3s', +}; diff --git a/web/components/ui/Statusbar/Statusbar.tsx b/web/components/ui/Statusbar/Statusbar.tsx index 56f9c823d..ab4503d3c 100644 --- a/web/components/ui/Statusbar/Statusbar.tsx +++ b/web/components/ui/Statusbar/Statusbar.tsx @@ -45,7 +45,7 @@ export default function Statusbar(props: Props) { } else { onlineMessage = 'Offline'; if (lastDisconnectTime) { - rightSideMessage = `Last live ${formatDistanceToNow(lastDisconnectTime)} ago.`; + rightSideMessage = `Last live ${formatDistanceToNow(new Date(lastDisconnectTime))} ago.`; } } diff --git a/web/components/video/OwncastPlayer.tsx b/web/components/video/OwncastPlayer.tsx index c433415aa..f93a77dd7 100644 --- a/web/components/video/OwncastPlayer.tsx +++ b/web/components/video/OwncastPlayer.tsx @@ -1,15 +1,26 @@ import React from 'react'; +import { useSetRecoilState } from 'recoil'; import VideoJS from './player'; import ViewerPing from './viewer-ping'; +import VideoPoster from './VideoPoster'; import { getLocalStorage, setLocalStorage } from '../../utils/helpers'; +import { videoStateAtom } from '../stores/ClientConfigStore'; +import { VideoState } from '../../interfaces/application-state'; const PLAYER_VOLUME = 'owncast_volume'; const ping = new ViewerPing(); -export default function OwncastPlayer(props) { +interface Props { + source: string; + online: boolean; +} + +export default function OwncastPlayer(props: Props) { const playerRef = React.useRef(null); - const { source } = props; + const { source, online } = props; + + const setVideoState = useSetRecoilState(videoStateAtom); const setSavedVolume = () => { try { @@ -51,7 +62,7 @@ export default function OwncastPlayer(props) { }, sources: [ { - src: `${source}/hls/stream.m3u8`, + src: source, type: 'application/x-mpegURL', }, ], @@ -75,6 +86,7 @@ export default function OwncastPlayer(props) { player.on('playing', () => { player.log('player is playing'); ping.start(); + setVideoState(VideoState.Playing); }); player.on('pause', () => { @@ -85,10 +97,26 @@ export default function OwncastPlayer(props) { player.on('ended', () => { player.log('player is ended'); ping.stop(); + setVideoState(VideoState.Unavailable); }); player.on('volumechange', handleVolume); }; - return ; + return ( +
+ {online && ( +
+ +
+ )} +
+ +
+
+ ); } diff --git a/web/components/video/Player.module.scss b/web/components/video/Player.module.scss index 66b2b8c75..dbf2dbd8e 100644 --- a/web/components/video/Player.module.scss +++ b/web/components/video/Player.module.scss @@ -1,5 +1,9 @@ .player { height: 80vh; width: 100%; - background-color: green; + background-color: black; + + .vjs-big-play-centered .vjs-big-play-button { + z-index: 99999 !important; + } } \ No newline at end of file diff --git a/web/components/video/VideoPoster.module.scss b/web/components/video/VideoPoster.module.scss new file mode 100644 index 000000000..fd9d94838 --- /dev/null +++ b/web/components/video/VideoPoster.module.scss @@ -0,0 +1,5 @@ +.poster { + background-color: black; + display: flex; + justify-content: center; +} \ No newline at end of file diff --git a/web/components/video/VideoPoster.tsx b/web/components/video/VideoPoster.tsx index f62db5db7..c589029cf 100644 --- a/web/components/video/VideoPoster.tsx +++ b/web/components/video/VideoPoster.tsx @@ -1,109 +1,46 @@ -/* -VideoPoster is the image that covers up the video component and shows a -preview of the video, refreshing every N seconds. -It's more complex than it needs to be, using the "double buffer" approach to -cross-fade the two images. Now that we've moved to React we may be able to -simply use some simple cross-fading component. -*/ +import { useEffect, useState } from 'react'; +import CrossfadeImage from '../ui/CrossfadeImage/CrossfadeImage'; +import s from './VideoPoster.module.scss'; -import { useEffect, useLayoutEffect, useState } from 'react'; -import { ReactElement } from 'react-markdown/lib/react-markdown'; +const REFRESH_INTERVAL = 20_000; -const REFRESH_INTERVAL = 15000; -const TEMP_IMAGE = 'http://localhost:8080/logo'; -const POSTER_BASE_URL = 'http://localhost:8080/'; +interface Props { + initialSrc: string; + src: string; + online: boolean; +} -export default function VideoPoster(props): ReactElement { - const { active } = props; - const [flipped, setFlipped] = useState(false); - const [oldUrl, setOldUrl] = useState(TEMP_IMAGE); - const [url, setUrl] = useState(props.url); - const [currentUrl, setCurrentUrl] = useState(TEMP_IMAGE); - const [loadingImage, setLoadingImage] = useState(TEMP_IMAGE); - const [offlineImage, setOfflineImage] = useState(TEMP_IMAGE); +export default function VideoPoster(props: Props) { + const { online, initialSrc, src: base } = props; - let refreshTimer = null; - - const setLoaded = () => { - setFlipped(!flipped); - setUrl(loadingImage); - setOldUrl(currentUrl); - }; - - const fire = () => { - const cachebuster = Math.round(new Date().getTime() / 1000); - setLoadingImage(`${POSTER_BASE_URL}?cb=${cachebuster}`); - const img = new Image(); - img.onload = setLoaded; - img.src = loadingImage; - }; - - const stopRefreshTimer = () => { - clearInterval(refreshTimer); - refreshTimer = null; - }; - - const startRefreshTimer = () => { - stopRefreshTimer(); - fire(); - // Load a new copy of the image every n seconds - refreshTimer = setInterval(fire, REFRESH_INTERVAL); - }; + let timer: ReturnType; + const [src, setSrc] = useState(initialSrc); + const [duration, setDuration] = useState('0s'); useEffect(() => { - if (active) { - fire(); - startRefreshTimer(); - } else { - stopRefreshTimer(); - } - }, [active]); + clearInterval(timer); + timer = setInterval(() => { + if (duration === '0s') { + setDuration('3s'); + } - // On component unmount. - useLayoutEffect( - () => () => { - stopRefreshTimer(); - }, - [], - ); - - // TODO: Replace this with React memo logic. - // shouldComponentUpdate(prevProps, prevState) { - // return ( - // this.props.active !== prevProps.active || - // this.props.offlineImage !== prevProps.offlineImage || - // this.state.url !== prevState.url || - // this.state.oldUrl !== prevState.oldUrl - // ); - // } - - if (!active) { - return ( -
- -
- ); - } + setSrc(`${base}?${Date.now()}`); + }, REFRESH_INTERVAL); + }, []); return ( -
- - +
+ {!online && logo} + + {online && ( + + )}
); } - -function ThumbImage({ url, visible }) { - if (!url) { - return null; - } - return ( -
- ); -} diff --git a/web/interfaces/application-state.ts b/web/interfaces/application-state.ts index 2e627562f..740dc7422 100644 --- a/web/interfaces/application-state.ts +++ b/web/interfaces/application-state.ts @@ -19,6 +19,12 @@ export enum ChatState { Offline, // Chat is offline/disconnected for some reason but is visible. } +export enum VideoState { + Available, // Play button should be visible and the user can begin playback. + Unavailable, // Play button not be visible and video is not available. + Playing, // Playback is taking place and the play button should not be shown. +} + export function getChatState(state: AppState): ChatState { switch (state) { case AppState.Loading: diff --git a/web/next.config.js b/web/next.config.js index 02bafbe2a..f5f3a601c 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -8,6 +8,18 @@ module.exports = withLess({ source: '/api/:path*', destination: 'http://localhost:8080/api/:path*', // Proxy to Backend to work around CORS. }, + { + source: '/hls/:path*', + destination: 'http://localhost:8080/hls/:path*', // Proxy to Backend to work around CORS. + }, + { + source: '/logo', + destination: 'http://localhost:8080/logo', // Proxy to Backend to work around CORS. + }, + { + source: '/thumbnail.jpg', + destination: 'http://localhost:8080/thumbnail.jpg', // Proxy to Backend to work around CORS. + }, ]; }, }); diff --git a/web/package-lock.json b/web/package-lock.json index 20b3d83ea..ff11c007a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -29,6 +29,7 @@ "react": "17.0.2", "react-chartkick": "0.5.2", "react-contenteditable": "^3.3.6", + "react-crossfade-img": "^1.0.0", "react-dom": "17.0.2", "react-linkify": "1.0.0-alpha", "react-markdown": "8.0.0", @@ -26610,6 +26611,15 @@ "react": ">=16.3" } }, + "node_modules/react-crossfade-img": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-crossfade-img/-/react-crossfade-img-1.0.0.tgz", + "integrity": "sha512-7RG0rvTBA/K7EWcDPzIwEqUzJgoX3+YvrGghxxoqT3xG8onyzVUNenG2WCQ/hzdhT2mIU8e7UD5C0GKS/iyMww==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-docgen": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.0.tgz", @@ -52302,6 +52312,12 @@ "prop-types": "^15.7.1" } }, + "react-crossfade-img": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-crossfade-img/-/react-crossfade-img-1.0.0.tgz", + "integrity": "sha512-7RG0rvTBA/K7EWcDPzIwEqUzJgoX3+YvrGghxxoqT3xG8onyzVUNenG2WCQ/hzdhT2mIU8e7UD5C0GKS/iyMww==", + "requires": {} + }, "react-docgen": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.0.tgz", diff --git a/web/package.json b/web/package.json index e18ff63d0..9a6a20509 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ "react": "17.0.2", "react-chartkick": "0.5.2", "react-contenteditable": "^3.3.6", + "react-crossfade-img": "^1.0.0", "react-dom": "17.0.2", "react-linkify": "1.0.0-alpha", "react-markdown": "8.0.0", @@ -95,4 +96,4 @@ "typescript": "4.5.5", "yaml": "^2.0.1" } -} \ No newline at end of file +} diff --git a/web/stories/Video.stories.tsx b/web/stories/Video.stories.tsx index ddc39a8a9..4e9f80bc3 100644 --- a/web/stories/Video.stories.tsx +++ b/web/stories/Video.stories.tsx @@ -3,9 +3,9 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'; import OwncastPlayer from '../components/video/OwncastPlayer'; const streams = { - DemoServer: `https://watch.owncast.online`, - RetroStrangeTV: `https://live.retrostrange.com`, - localhost: `http://localhost:8080`, + DemoServer: `https://watch.owncast.online/hls/stream.m3u8`, + RetroStrangeTV: `https://live.retrostrange.com/hls/stream.m3u8`, + localhost: `http://localhost:8080/hls/stream.m3u8`, }; export default { @@ -28,5 +28,5 @@ const Template: ComponentStory = args => ; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const Template: ComponentStory = args => ; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const Example1 = Template.bind({}); +Example1.args = { + initialSrc: 'https://watch.owncast.online/logo', + src: 'https://watch.owncast.online/thumbnail.jpg', + online: true, +}; + +export const Example2 = Template.bind({}); +Example2.args = { + initialSrc: 'https://listen.batstationrad.io/logo', + src: 'https://listen.batstationrad.io//thumbnail.jpg', + online: true, +}; + +export const Offline = Template.bind({}); +Offline.args = { + initialSrc: 'https://watch.owncast.online/logo', + src: 'https://watch.owncast.online/thumbnail.jpg', + online: false, +};