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 { 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;
player: VideoJsPlayer;
componentDidMount() {
this.player = videojs(this.videoNode, this.props, function onPlayerReady() {
console.log('Video.js Ready', this);
});
const {
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() {
@ -21,6 +35,13 @@ export default class VideoPlayer extends React.Component<VideoJsPlayerOptions> {
}
render() {
const {
sources,
controls,
autoplay,
onReady,
...videoJsOptions
} = this.props;
return (
<ResponsiveEmbed aspectRatio="16by9">
<div>
@ -29,7 +50,34 @@ export default class VideoPlayer extends React.Component<VideoJsPlayerOptions> {
<video
ref={(node) => { this.videoNode = node; }}
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>
</ResponsiveEmbed>

View File

@ -14,7 +14,8 @@ import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { VideoEntry } from 'util/datatypes/VideoList';
import DownloadButton from 'components/DownloadButton';
import { basename } from 'path';
import { getHLSMasterURL } from '../../util';
import withSession from 'util/session';
import { getDASHManifestURL, getHLSMasterURL } from '../../util';
import VideoPlayer from '../../components/VideoPlayer';
import CopyField from '../../components/CopyField';
@ -22,12 +23,14 @@ import { FormattedMessage } from '../../components/localization';
import sanitizeFileName from '../../util/sanitizeFileName';
import sanitizeTitle from '../../util/sanitizeTitle';
import { notFound } from '../../util/status';
import { getIndex, getVideos } from '../../util/api';
export const getServerSideProps: GetServerSideProps = async (context) => {
const { req }: { req: IncomingMessage } = context;
const { id, vslug } = context.params || {};
import {
getDownloadURL,
getIndex,
getVideos,
submitPreferences,
} from '../../util/api';
const getProps = withSession(async (req, _res, { id, vslug }: { id: string, vslug: string }) => {
if (typeof id !== 'string') {
throw new Error('only expected a single id');
}
@ -110,31 +113,66 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
// let basePath = null;
// 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}`;
// 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
return {
props: {
id,
vslug,
video,
volume,
title,
hlsServerURL,
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,
},
};
};
});
export const getServerSideProps: GetServerSideProps = async ({
req,
res,
params,
}) => getProps(req, res, params);
export default function VideoPlayerPage({
id,
vslug,
video,
volume,
// extra,
redirect,
title,
hlsServerURL,
// dashServerURL,
dashServerURL,
basePath,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (redirect) {
@ -162,6 +200,8 @@ export default function VideoPlayerPage({
const intl = useIntl();
const playerRef = React.useRef<VideoPlayer>();
const {
fileName,
title: videoTitle,
@ -169,6 +209,23 @@ export default function VideoPlayerPage({
sourceVideoStart,
} = 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 (
<div>
<Head>
@ -190,6 +247,12 @@ export default function VideoPlayerPage({
<link rel="icon" href="/favicon.ico" />
</Head>
{/* <pre>
<code>
{extra}
</code>
</pre> */}
<Breadcrumb>
<Link passHref href="/">
<Breadcrumb.Item>
@ -209,17 +272,32 @@ export default function VideoPlayerPage({
</Breadcrumb>
<VideoPlayer
ref={playerRef}
autoplay
controls
defaultVolume={volume}
language="en"
playbackRates={[
0.25,
0.5,
0.75,
1,
1.25,
1.5,
1.75,
2,
]}
onVolumeChange={onVolumeChange}
sources={[
// getDASHManifestURL(dashServerURL, id, fileName),
{ src: getHLSMasterURL(hlsServerURL, id, fileName) },
{ src: getHLSMasterURL(hlsServerURL, id, fileName), type: 'application/x-mpegURL' },
{ src: getDASHManifestURL(dashServerURL, id, fileName), type: 'application/dash+xml' },
{ src: getDownloadURL(id, fileName), type: 'video/mp4' },
]}
aspectRatio="16:9"
fill
/>
<h1>
<h1 className="mb-3 mt-3">
{title}
:
{' '}
@ -230,27 +308,31 @@ export default function VideoPlayerPage({
<Col sm={12} md={7}>
<ButtonGroup>
<DownloadButton id={id} fileName={fileName} />
{sourceVideoURL ? (
<Button
href={
sourceVideoStart
? `${sourceVideoURL}?t=${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60
}s`
: sourceVideoURL
}
target="blank"
variant="twitch"
>
<FontAwesomeIcon icon={['fab', 'twitch']} className="mr-2" />
<FormattedMessage
id="VideoPlayerPage.watchOnTwitch"
description="Button below video that links to the exact position in the archived stream to watch it there."
defaultMessage="Watch on Twitch"
/>
</Button>
) : (
''
)}
{
sourceVideoURL
? (
<Button
href={
sourceVideoStart
? `${sourceVideoURL}?t=${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60
}s`
: sourceVideoURL
}
target="blank"
variant="twitch"
>
<FontAwesomeIcon icon={['fab', 'twitch']} className="mr-2" />
<FormattedMessage
id="VideoPlayerPage.watchOnTwitch"
description="Button below video that links to the exact position in the archived stream to watch it there."
defaultMessage="Watch on Twitch"
/>
</Button>
)
: (
''
)
}
</ButtonGroup>
</Col>
<Col sm={12} md={5}>

View File

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

View File

@ -11,6 +11,11 @@ export default withSession(async (req, res) => {
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) {
const enableDark = parseBool(req.body.enableDark);
req.session.set('enable-dark', enableDark);

View File

@ -4,11 +4,13 @@ import withSession from '../../util/session';
export default withSession(async (req, res) => {
const enableDark = req.session.get('enable-dark') || false;
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.json({
enableDark,
locale,
volume,
});
});

View File

@ -63,3 +63,28 @@ export function getDownloadURL(id: string, fileName: string): string {
return [upstreamURL, encodeURIComponent(id), encodeURIComponent(fileName)]
.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,
});
}