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 '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>
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue