From e284348c1c9aa8a2966a29c4f70ca60b9729897e Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sun, 3 Jan 2021 18:52:45 +0100 Subject: [PATCH] Improve user preferences and allow volume to be saved. --- frontend/components/LocaleSwitcher.jsx | 90 +++++++++++++++ frontend/components/VideoPlayer.tsx | 58 +++++++++- frontend/pages/[id]/[vslug].tsx | 146 ++++++++++++++++++------ frontend/pages/_app.tsx | 4 +- frontend/pages/api/changePreferences.ts | 5 + frontend/pages/api/user.ts | 2 + frontend/util/api.ts | 25 ++++ 7 files changed, 291 insertions(+), 39 deletions(-) create mode 100644 frontend/components/LocaleSwitcher.jsx diff --git a/frontend/components/LocaleSwitcher.jsx b/frontend/components/LocaleSwitcher.jsx new file mode 100644 index 0000000..40e9b05 --- /dev/null +++ b/frontend/components/LocaleSwitcher.jsx @@ -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 ( + <> + + + ); +} diff --git a/frontend/components/VideoPlayer.tsx b/frontend/components/VideoPlayer.tsx index 2a63741..5a946ce 100644 --- a/frontend/components/VideoPlayer.tsx +++ b/frontend/components/VideoPlayer.tsx @@ -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 { +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 { } render() { + const { + sources, + controls, + autoplay, + onReady, + ...videoJsOptions + } = this.props; return (
@@ -29,7 +50,34 @@ export default class VideoPlayer extends React.Component {
diff --git a/frontend/pages/[id]/[vslug].tsx b/frontend/pages/[id]/[vslug].tsx index d23849b..8257fba 100644 --- a/frontend/pages/[id]/[vslug].tsx +++ b/frontend/pages/[id]/[vslug].tsx @@ -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 ''; + // }).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) { if (redirect) { @@ -162,6 +200,8 @@ export default function VideoPlayerPage({ const intl = useIntl(); + const playerRef = React.useRef(); + 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 (
@@ -190,6 +247,12 @@ export default function VideoPlayerPage({ + {/*
+        
+          {extra}
+        
+      
*/} + @@ -209,17 +272,32 @@ export default function VideoPlayerPage({ -

+

{title} : {' '} @@ -230,27 +308,31 @@ export default function VideoPlayerPage({ - {sourceVideoURL ? ( - - ) : ( - '' - )} + { + sourceVideoURL + ? ( + + ) + : ( + '' + ) + } diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index 59d3836..be009f0 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -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); diff --git a/frontend/pages/api/changePreferences.ts b/frontend/pages/api/changePreferences.ts index 7df1139..5b25a03 100644 --- a/frontend/pages/api/changePreferences.ts +++ b/frontend/pages/api/changePreferences.ts @@ -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); diff --git a/frontend/pages/api/user.ts b/frontend/pages/api/user.ts index c113aab..108424b 100644 --- a/frontend/pages/api/user.ts +++ b/frontend/pages/api/user.ts @@ -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, }); }); diff --git a/frontend/util/api.ts b/frontend/util/api.ts index 3f6348a..584001b 100644 --- a/frontend/util/api.ts +++ b/frontend/util/api.ts @@ -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, + }); +}