Improve user preferences and allow volume to be saved.
parent
98b03e2e4a
commit
e284348c1c
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue