gdq-archive/frontend/pages/[id]/[vslug].tsx

485 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* Copyright (C) 2019-2021 Carl Kittelberger <icedream@icedream.pw>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
Breadcrumb, Button, ButtonGroup, Col, Ratio, Row, Tab, Tabs,
} from 'react-bootstrap';
import { useIntl } from 'react-intl';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { GetServerSideProps, GetServerSidePropsResult, InferGetServerSidePropsType } from 'next';
import { VideoEntry } from 'util/datatypes/VideoList';
import { basename } from 'path';
import withSession from 'util/session';
import { ParsedUrlQuery } from 'querystring';
import YouTubePlayer from 'react-youtube';
import { faTwitch, faYoutube } from '@fortawesome/free-brands-svg-icons';
import { getDASHManifestURL, getHLSMasterURL } from '../../util';
import VideoPlayer from '../../components/VideoPlayer';
import CopyField from '../../components/CopyField';
import { FormattedMessage } from '../../components/localization';
import sanitizeFileName from '../../util/sanitizeFileName';
import { notFound } from '../../util/status';
import {
getDownloadURL,
getIndex,
getVideos,
submitPreferences,
} from '../../util/api';
import DownloadButton from '../../components/DownloadButton';
interface VideoPlayerPageParameters extends ParsedUrlQuery {
id: string,
vslug: string,
}
interface VideoPlayerPageProps {
id?: string,
vslug?: string,
video?: number | VideoEntry,
volume?: number,
redirect?: boolean,
title?: string,
hlsServerURL?: string,
dashServerURL?: string,
basePath?: string,
twitchPlayerParentKey?: string,
}
const getProps = withSession(async (req, _res, { id, vslug }: VideoPlayerPageParameters): Promise<GetServerSidePropsResult<VideoPlayerPageProps>> => {
if (typeof id !== 'string') {
throw new Error('only expected a single id');
}
if (typeof vslug !== 'string') {
throw new Error('only expected a single vslug');
}
// Fetch URL to thumbnails server
const {
ids,
servers: { hls: hlsServerURL, dash: dashServerURL },
} = await getIndex();
const vodMeta = ids.find(({
id: thisID,
}: {
id: string
}) => id === thisID);
if (!vodMeta) {
return { props: {} };
}
// Fetch list of videos for this VOD ID
const vodInfo = await getVideos(id);
let videos;
if (Array.isArray(vodInfo)) {
videos = vodInfo;
} else {
videos = vodInfo.videos;
}
// Check if vslug is actually an index number (old app style)
/*
NOTE - parseInt will accept strings CONTAINING numbers.
This is supposed to reject strings that are not JUST numbers.
*/
const vindexNum = +vslug;
if (!Number.isNaN(vindexNum)) {
// Check if video exists
if (vindexNum < 0 || vindexNum >= videos.length) {
return { props: {} };
}
const video = videos[vindexNum];
return {
props: {
redirect: true,
id,
video,
vslug: video.slug,
},
};
}
// Check if vslug is actually point to a file name
const sanitizedFileName = `${sanitizeFileName(basename(vslug, '.mp4'))}.mp4`;
const realVIndex = videos.findIndex(
(video: VideoEntry) => video.downloadFileName === sanitizedFileName,
);
if (realVIndex >= 0) {
const video = videos[realVIndex];
return {
props: {
redirect: true,
id,
video: realVIndex,
vslug: video.slug,
},
};
}
// Check if we can find any video with matching vslug
const video = videos.find(({ slug }: VideoEntry) => slug === vslug);
if (!video) {
return { props: {} };
}
// At this point we found the right video, just get more information at this point
const { title } = vodMeta;
// 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;
}
if (!req.headers.host) {
throw new Error(JSON.stringify(req.headers));
}
// Pass data to the page via props
return {
props: {
id,
vslug,
video,
volume,
title,
hlsServerURL,
dashServerURL,
twitchPlayerParentKey: req.headers.host.split(':')[0],
basePath,
},
};
});
export const getServerSideProps: GetServerSideProps<VideoPlayerPageProps, VideoPlayerPageParameters> = async ({
req,
res,
params,
}) => getProps(req, res, params);
export default function VideoPlayerPage({
id,
vslug,
video,
volume,
// extra,
redirect,
title,
hlsServerURL,
dashServerURL,
basePath,
twitchPlayerParentKey,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (redirect) {
const router = useRouter();
React.useEffect(() => {
router.push(`/${id}/${vslug}`);
});
return (
<p>
You will be redirected
{' '}
<Link href="/[id]/[vslug]" as={`/${id}/${vslug}`}>
<span>here</span>
</Link>
</p>
);
}
if (!video) {
return notFound();
}
const intl = useIntl();
const playerRef = React.useRef<VideoPlayer>();
if (typeof video === 'number') {
throw new Error('Logic error - video should not be a number');
}
const {
downloadFileName,
title: videoTitle,
sourceVideoID,
sourceVideoURL,
sourceVideoStart: sourceVideoStartStr,
run,
youtubeVideoID,
} = video;
// convert possibly-string sourceVideoStart field to actual number
let sourceVideoStart: number;
if (typeof sourceVideoStartStr === 'string') {
sourceVideoStart = parseInt(sourceVideoStartStr, 10);
} else {
sourceVideoStart = sourceVideoStartStr;
}
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);
}
}
const tabs: Array<React.ReactElement<typeof Tab>> = [];
let defaultTab = 'mirror-player';
const downloadButtons: Array<React.ReactElement> = [];
// Mirror player tab
if (typeof downloadFileName === 'string' && downloadFileName.length > 0) {
tabs.push(
<Tab
eventKey="mirror-player"
title={intl.formatMessage({
id: 'VideoPlayerPage.tab.mirrorPlayer',
description: 'Title for mirror player tab',
defaultMessage: 'Mirror player',
})}
>
<VideoPlayer
ref={playerRef}
autoplay
controls
defaultVolume={volume}
language="en"
playbackRates={[
0.25,
0.5,
0.75,
1,
1.25,
1.5,
1.75,
2,
]}
// eslint-disable-next-line react/jsx-no-bind
onVolumeChange={onVolumeChange}
sources={[
{ src: getHLSMasterURL(hlsServerURL, id, downloadFileName), type: 'application/x-mpegURL' },
{ src: getDASHManifestURL(dashServerURL, id, downloadFileName), type: 'application/dash+xml' },
{ src: getDownloadURL(id, downloadFileName), type: 'video/mp4' },
]}
aspectRatio="16:9"
fill
/>
</Tab>,
);
downloadButtons.push(<DownloadButton
id={id}
fileName={getDownloadURL(id, downloadFileName)}
/>);
}
// Twitch tab
if (sourceVideoID) {
const twitchEmbedURL = new URL('https://player.twitch.tv');
twitchEmbedURL.searchParams.append('video', sourceVideoID);
twitchEmbedURL.searchParams.append('parent', twitchPlayerParentKey);
twitchEmbedURL.searchParams.append('t', `${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60}s`);
tabs.unshift(
<Tab
key="twitch-player"
eventKey="twitch-player"
title={intl.formatMessage({
id: 'VideoPlayerPage.tab.twitchPlayer',
description: 'Title for Twitch player tab',
defaultMessage: 'Twitch player',
})}
>
<Ratio aspectRatio="16x9">
<iframe
src={twitchEmbedURL.toString()}
title="Twitch embed"
frameBorder={0}
allowFullScreen
/>
</Ratio>
</Tab>,
);
defaultTab = 'twitch-player';
const twitchWatchURL = new URL(sourceVideoURL);
twitchWatchURL.searchParams.append('t', `${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60}s`);
downloadButtons.push(
<Button
href={twitchWatchURL.toString()}
target="blank"
variant="twitch"
key="watchOnTwitch"
>
<FontAwesomeIcon icon={faTwitch} 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>,
);
}
// YouTube tab
if (youtubeVideoID) {
const youtubeURL = new URL(youtubeVideoID, 'https://youtu.be/');
tabs.unshift(
<Tab
key="youtube-player"
eventKey="youtube-player"
title={intl.formatMessage({
id: 'VideoPlayerPage.tab.youtubePlayer',
description: 'Title for YouTube player tab',
defaultMessage: 'YouTube player',
})}
>
<YouTubePlayer
className={[
'ratio',
'ratio-16by9',
'ratio-16x9',
].join(' ')}
videoId={youtubeVideoID}
opts={{
playerVars: {
// https://developers.google.com/youtube/player_parameters
autoplay: 1,
color: 'white',
start: youtubeURL.searchParams.has('t')
? parseInt(youtubeURL.searchParams.get('t').toString(), 10)
: 0,
},
}}
/>
</Tab>,
);
defaultTab = 'youtube-player';
downloadButtons.push(
<Button
href={youtubeURL.toString()}
target="blank"
variant="youtube"
key="watchOnYouTube"
>
<FontAwesomeIcon icon={faYoutube} className="mr-2" />
{' '}
<FormattedMessage
id="VideoPlayerPage.watchOnYouTube"
description="Button below video that links user to the YouTube upload of the VOD."
defaultMessage="Watch on YouTube"
/>
</Button>,
);
}
return (
<div>
<Head>
<title>
{`${videoTitle} ${title} ${intl.formatMessage({
id: 'App.title',
description: 'The full title of the website',
defaultMessage: 'Games Done Quick Instant Archive',
})}`}
</title>
</Head>
<Breadcrumb>
<Link legacyBehavior passHref href="/">
<Breadcrumb.Item>
<FormattedMessage
id="Breadcrumb.homeTitle"
defaultMessage="GDQ Instant Archive"
description="Root node text in breadcrumb"
/>
</Breadcrumb.Item>
</Link>
<Link legacyBehavior passHref href="/[id]" as={`/${id}`}>
<Breadcrumb.Item>{title}</Breadcrumb.Item>
</Link>
<Link legacyBehavior passHref href="/[id]/[vslug]" as={`/${id}/${vslug}`}>
<Breadcrumb.Item active>{videoTitle}</Breadcrumb.Item>
</Link>
</Breadcrumb>
<Tabs
defaultActiveKey={defaultTab}
unmountOnExit
>
{tabs}
</Tabs>
<h1 className="mt-3">
{title}
:
{' '}
{videoTitle}
</h1>
<h3 className="mb-3">
{run?.category}
</h3>
<Row className="mb-3">
<Col sm={12} md={7}>
<ButtonGroup>
{downloadButtons}
</ButtonGroup>
</Col>
<Col sm={12} md={5}>
<CopyField icon="share">
{[basePath, id, vslug].join('/')}
</CopyField>
</Col>
</Row>
</div>
);
}