Improve user preferences and allow volume to be saved.

master
Icedream 2021-01-03 18:52:45 +01:00
parent 98b03e2e4a
commit e284348c1c
7 changed files with 291 additions and 39 deletions

View File

@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
// import useSWR from 'swr';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
Button,
// Spinner
} from 'react-bootstrap';
import useSWR from 'swr';
import { FormattedMessage } from 'react-intl';
import { fetchJson } from '../util/api';
let darkToggleAnimationTimer = null;
function triggerDarkToggleAnimation() {
document.documentElement.setAttribute('data-toggled-dark', 'true');
if (darkToggleAnimationTimer !== null) {
clearTimeout(darkToggleAnimationTimer);
}
darkToggleAnimationTimer = setTimeout(() => {
document.documentElement.removeAttribute('data-toggled-dark');
}, 1200);
}
export default function DarkToggler() {
const {
data, error, isValidating, mutate,
} = useSWR('/api/user');
let enableDark = false;
let isLoading = false;
if (isValidating || !data) {
isLoading = true;
}
if (error) {
console.warn(error);
isLoading = false;
} else if (data) {
enableDark = data.enableDark;
}
useEffect(() => {
if (!isLoading) {
console.info('enable dark:', enableDark);
if (enableDark) {
document.documentElement.setAttribute('data-enable-dark', 'true');
} else {
document.documentElement.removeAttribute('data-enable-dark');
}
}
});
return (
<>
<Button
onClick={async () => {
const { enableDark: newEnableDark } = await fetchJson('/api/toggleDark');
triggerDarkToggleAnimation();
mutate({ enableDark: newEnableDark }, true);
}}
disabled={isLoading}
variant="outline-secondary"
active={!enableDark}
>
{/*
isLoading
? (
<Spinner animation="border" role="status" size="sm">
<span className="sr-only">
<FormattedMessage
id="DarkToggler.loading"
defaultMessage="Loading…"
/>
</span>
</Spinner>
)
: ''
*/}
<FontAwesomeIcon icon={[enableDark ? 'far' : 'fa', 'lightbulb']} />
<span className="sr-only">
<FormattedMessage
id="DarkToggler.screenReaderText"
defaultMessage="Toggle dark mode"
description="Screen reader description of the dark mode toggle button"
/>
</span>
</Button>
</>
);
}

View File

@ -3,15 +3,29 @@ import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js';
// import 'videojs-contrib-dash'; // import 'videojs-contrib-dash';
import { ResponsiveEmbed } from 'react-bootstrap'; import { ResponsiveEmbed } from 'react-bootstrap';
export default class VideoPlayer extends React.Component<VideoJsPlayerOptions> { export default class VideoPlayer extends React.Component<{
onReady?: () => void,
onVolumeChange?: (e: Event) => void,
onTimeUpdate?: (e: Event) => void,
} & VideoJsPlayerOptions> {
videoNode: HTMLVideoElement; videoNode: HTMLVideoElement;
player: VideoJsPlayer; player: VideoJsPlayer;
componentDidMount() { componentDidMount() {
this.player = videojs(this.videoNode, this.props, function onPlayerReady() { const {
console.log('Video.js Ready', this); onReady,
}); onTimeUpdate,
onVolumeChange,
defaultVolume,
...videoJsOptions
} = this.props;
this.player = videojs(this.videoNode, videoJsOptions, onReady);
if (typeof defaultVolume === 'number' && !Number.isNaN(defaultVolume)) {
this.player.volume(defaultVolume);
}
if (onVolumeChange) { this.player.on('volumechange', onVolumeChange); }
if (onTimeUpdate) { this.player.on('timeupdate', onTimeUpdate); }
} }
componentWillUnmount() { componentWillUnmount() {
@ -21,6 +35,13 @@ export default class VideoPlayer extends React.Component<VideoJsPlayerOptions> {
} }
render() { render() {
const {
sources,
controls,
autoplay,
onReady,
...videoJsOptions
} = this.props;
return ( return (
<ResponsiveEmbed aspectRatio="16by9"> <ResponsiveEmbed aspectRatio="16by9">
<div> <div>
@ -29,7 +50,34 @@ export default class VideoPlayer extends React.Component<VideoJsPlayerOptions> {
<video <video
ref={(node) => { this.videoNode = node; }} ref={(node) => { this.videoNode = node; }}
className="video-js" className="video-js"
/> data-setup={
JSON.stringify({
autoplay,
controls,
...videoJsOptions,
})
}
controls={controls}
autoPlay={!!(autoplay === 'true' || autoplay === true)}
>
{sources.map(
({ src, type }) => (
<source
key={`${JSON.stringify({ src, type })}`}
src={src}
type={type}
/>
),
)}
<p className="vjs-no-js">
{/* TODO - localize */}
To view this video please enable JavaScript, and consider upgrading to a
web browser that
<a href="https://videojs.com/html5-video-support/" target="_blank" rel="noreferrer">
supports HTML5 video
</a>
</p>
</video>
</div> </div>
</div> </div>
</ResponsiveEmbed> </ResponsiveEmbed>

View File

@ -14,7 +14,8 @@ import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { VideoEntry } from 'util/datatypes/VideoList'; import { VideoEntry } from 'util/datatypes/VideoList';
import DownloadButton from 'components/DownloadButton'; import DownloadButton from 'components/DownloadButton';
import { basename } from 'path'; import { basename } from 'path';
import { getHLSMasterURL } from '../../util'; import withSession from 'util/session';
import { getDASHManifestURL, getHLSMasterURL } from '../../util';
import VideoPlayer from '../../components/VideoPlayer'; import VideoPlayer from '../../components/VideoPlayer';
import CopyField from '../../components/CopyField'; import CopyField from '../../components/CopyField';
@ -22,12 +23,14 @@ import { FormattedMessage } from '../../components/localization';
import sanitizeFileName from '../../util/sanitizeFileName'; import sanitizeFileName from '../../util/sanitizeFileName';
import sanitizeTitle from '../../util/sanitizeTitle'; import sanitizeTitle from '../../util/sanitizeTitle';
import { notFound } from '../../util/status'; import { notFound } from '../../util/status';
import { getIndex, getVideos } from '../../util/api'; import {
getDownloadURL,
export const getServerSideProps: GetServerSideProps = async (context) => { getIndex,
const { req }: { req: IncomingMessage } = context; getVideos,
const { id, vslug } = context.params || {}; submitPreferences,
} from '../../util/api';
const getProps = withSession(async (req, _res, { id, vslug }: { id: string, vslug: string }) => {
if (typeof id !== 'string') { if (typeof id !== 'string') {
throw new Error('only expected a single id'); throw new Error('only expected a single id');
} }
@ -110,31 +113,66 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
// let basePath = null; // let basePath = null;
// basePath = `${JSON.stringify(req.toString())}`; // basePath = `${JSON.stringify(req.toString())}`;
// TODO - detect HTTPS properly
// NOTE - req.socket.encrypted fails in TypeScript due to typing
const basePath = `https://${req.headers.host}`; const basePath = `https://${req.headers.host}`;
// ask for user's volume preference
let volume: number = 0.5;
const volumeSessionValue = req.session.get('volume');
if (typeof volumeSessionValue === 'string') {
const parsedNumber = parseFloat(volumeSessionValue);
if (!Number.isNaN(parsedNumber)) {
volume = parsedNumber;
}
} else if (typeof volumeSessionValue === 'number' && !Number.isNaN(volumeSessionValue)) {
volume = volumeSessionValue;
}
// Pass data to the page via props // Pass data to the page via props
return { return {
props: { props: {
id, id,
vslug, vslug,
video, video,
volume,
title, title,
hlsServerURL, hlsServerURL,
dashServerURL, dashServerURL,
// extra: ((o) => Object
// .keys(o)
// .map((k) => {
// if (k === undefined || k === null) {
// return JSON.stringify(k);
// }
// if (k.toString) {
// return k.toString();
// }
// return '<something>';
// }).join('\n'))(req.socket),
basePath, basePath,
}, },
}; };
}; });
export const getServerSideProps: GetServerSideProps = async ({
req,
res,
params,
}) => getProps(req, res, params);
export default function VideoPlayerPage({ export default function VideoPlayerPage({
id, id,
vslug, vslug,
video, video,
volume,
// extra,
redirect, redirect,
title, title,
hlsServerURL, hlsServerURL,
// dashServerURL, dashServerURL,
basePath, basePath,
}: InferGetServerSidePropsType<typeof getServerSideProps>) { }: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (redirect) { if (redirect) {
@ -162,6 +200,8 @@ export default function VideoPlayerPage({
const intl = useIntl(); const intl = useIntl();
const playerRef = React.useRef<VideoPlayer>();
const { const {
fileName, fileName,
title: videoTitle, title: videoTitle,
@ -169,6 +209,23 @@ export default function VideoPlayerPage({
sourceVideoStart, sourceVideoStart,
} = video; } = video;
let volumeChangeDebounceTimer: NodeJS.Timeout = null;
const [currentVolume, trackCurrentVolume] = React.useState(volume);
async function onVolumeChange() {
const newVolume = playerRef.current.player.volume();
if (Math.abs(newVolume - currentVolume) > 0.05) {
if (volumeChangeDebounceTimer !== null) {
clearTimeout(volumeChangeDebounceTimer);
}
volumeChangeDebounceTimer = setTimeout(() => {
submitPreferences({
volume: newVolume,
});
trackCurrentVolume(newVolume);
}, 1000);
}
}
return ( return (
<div> <div>
<Head> <Head>
@ -190,6 +247,12 @@ export default function VideoPlayerPage({
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
{/* <pre>
<code>
{extra}
</code>
</pre> */}
<Breadcrumb> <Breadcrumb>
<Link passHref href="/"> <Link passHref href="/">
<Breadcrumb.Item> <Breadcrumb.Item>
@ -209,17 +272,32 @@ export default function VideoPlayerPage({
</Breadcrumb> </Breadcrumb>
<VideoPlayer <VideoPlayer
ref={playerRef}
autoplay autoplay
controls controls
defaultVolume={volume}
language="en"
playbackRates={[
0.25,
0.5,
0.75,
1,
1.25,
1.5,
1.75,
2,
]}
onVolumeChange={onVolumeChange}
sources={[ sources={[
// getDASHManifestURL(dashServerURL, id, fileName), { src: getHLSMasterURL(hlsServerURL, id, fileName), type: 'application/x-mpegURL' },
{ src: getHLSMasterURL(hlsServerURL, id, fileName) }, { src: getDASHManifestURL(dashServerURL, id, fileName), type: 'application/dash+xml' },
{ src: getDownloadURL(id, fileName), type: 'video/mp4' },
]} ]}
aspectRatio="16:9" aspectRatio="16:9"
fill fill
/> />
<h1> <h1 className="mb-3 mt-3">
{title} {title}
: :
{' '} {' '}
@ -230,27 +308,31 @@ export default function VideoPlayerPage({
<Col sm={12} md={7}> <Col sm={12} md={7}>
<ButtonGroup> <ButtonGroup>
<DownloadButton id={id} fileName={fileName} /> <DownloadButton id={id} fileName={fileName} />
{sourceVideoURL ? ( {
<Button sourceVideoURL
href={ ? (
sourceVideoStart <Button
? `${sourceVideoURL}?t=${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60 href={
}s` sourceVideoStart
: sourceVideoURL ? `${sourceVideoURL}?t=${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60
} }s`
target="blank" : sourceVideoURL
variant="twitch" }
> target="blank"
<FontAwesomeIcon icon={['fab', 'twitch']} className="mr-2" /> variant="twitch"
<FormattedMessage >
id="VideoPlayerPage.watchOnTwitch" <FontAwesomeIcon icon={['fab', 'twitch']} className="mr-2" />
description="Button below video that links to the exact position in the archived stream to watch it there." <FormattedMessage
defaultMessage="Watch on Twitch" id="VideoPlayerPage.watchOnTwitch"
/> description="Button below video that links to the exact position in the archived stream to watch it there."
</Button> defaultMessage="Watch on Twitch"
) : ( />
'' </Button>
)} )
: (
''
)
}
</ButtonGroup> </ButtonGroup>
</Col> </Col>
<Col sm={12} md={5}> <Col sm={12} md={5}>

View File

@ -155,7 +155,7 @@ function GDQArchiveApp({
body: formData, body: formData,
}); });
mutate({ locale: value }); mutate({ ...userData, locale: value });
setMessages(await loadLocaleData(value)); setMessages(await loadLocaleData(value));
setIsChangingLocale(false); setIsChangingLocale(false);
@ -190,7 +190,7 @@ function GDQArchiveApp({
body: formData, body: formData,
}); });
mutate({ enableDark: value }); mutate({ ...userData, enableDark: value });
triggerDarkToggleAnimation(); triggerDarkToggleAnimation();
setIsChangingDarkMode(false); setIsChangingDarkMode(false);

View File

@ -11,6 +11,11 @@ export default withSession(async (req, res) => {
return; return;
} }
if (req.body.volume !== null && req.body.volume !== undefined) {
const volume = parseFloat(req.body.volume);
req.session.set('volume', volume);
}
if (req.body.enableDark) { if (req.body.enableDark) {
const enableDark = parseBool(req.body.enableDark); const enableDark = parseBool(req.body.enableDark);
req.session.set('enable-dark', enableDark); req.session.set('enable-dark', enableDark);

View File

@ -4,11 +4,13 @@ import withSession from '../../util/session';
export default withSession(async (req, res) => { export default withSession(async (req, res) => {
const enableDark = req.session.get('enable-dark') || false; const enableDark = req.session.get('enable-dark') || false;
const locale = req.session.get('locale') || defaultLocale; const locale = req.session.get('locale') || defaultLocale;
const volume = req.session.get('volume') || 0.5;
res.setHeader('cache-control', 'public, max-age=0, must-revalidate'); res.setHeader('cache-control', 'public, max-age=0, must-revalidate');
res.json({ res.json({
enableDark, enableDark,
locale, locale,
volume,
}); });
}); });

View File

@ -63,3 +63,28 @@ export function getDownloadURL(id: string, fileName: string): string {
return [upstreamURL, encodeURIComponent(id), encodeURIComponent(fileName)] return [upstreamURL, encodeURIComponent(id), encodeURIComponent(fileName)]
.join('/'); .join('/');
} }
export function submitPreferences(data: {
enableDark?: boolean,
locale?: string,
volume?: number,
}) {
const formData = new URLSearchParams();
if (data.enableDark !== undefined && data.enableDark !== null) {
formData.append('enableDark', data.enableDark.toString());
}
if (data.locale !== undefined && data.locale !== null) {
formData.append('locale', data.locale);
}
if (data.volume !== undefined && data.volume !== null) {
formData.append('volume', data.volume.toString());
}
fetchJson('/api/changePreferences', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
});
}