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

404 lines
11 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, ResponsiveEmbed, 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 DownloadButton from 'components/DownloadButton';
import { basename } from 'path';
import withSession from 'util/session';
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 sanitizeTitle from '../../util/sanitizeTitle';
import { notFound } from '../../util/status';
import {
getDownloadURL,
getIndex,
getVideos,
submitPreferences,
} from '../../util/api';
interface VideoPlayerPageParameters {
id: string,
vslug: string,
}
interface VideoPlayerPageProps {
id?: string,
vslug?: string,
video?: number,
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.fileName === 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 = 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>();
const {
fileName,
title: videoTitle,
sourceVideoURL,
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);
}
}
const twitchEmbedURL = new URL("https://player.twitch.tv");
twitchEmbedURL.searchParams.append('video', sourceVideoURL.split('/').pop());
twitchEmbedURL.searchParams.append('parent', twitchPlayerParentKey);
twitchEmbedURL.searchParams.append('t', `${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60}s`)
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>
<link rel="icon" href="/favicon.ico" />
</Head>
<Breadcrumb>
<Link passHref href="/">
<Breadcrumb.Item>
<FormattedMessage
id="Breadcrumb.homeTitle"
defaultMessage="GDQ Instant Archive"
description="Root node text in breadcrumb"
/>
</Breadcrumb.Item>
</Link>
<Link passHref href="/[id]" as={`/${id}`}>
<Breadcrumb.Item>{title}</Breadcrumb.Item>
</Link>
<Link passHref href="/[id]/[vslug]" as={`/${id}/${vslug}`}>
<Breadcrumb.Item active>{videoTitle}</Breadcrumb.Item>
</Link>
</Breadcrumb>
<Tabs
defaultActiveKey="twitch-player"
unmountOnExit={true}
>
<Tab
eventKey="twitch-player"
title={intl.formatMessage({
id: 'VideoPlayerPage.tab.twitchPlayer',
description: 'Title for Twitch player tab',
defaultMessage: 'Twitch player',
})}
>
<ResponsiveEmbed aspectRatio="16by9">
<iframe
src={twitchEmbedURL.toString()}
frameBorder={0}
allowFullScreen>
</iframe>
</ResponsiveEmbed>
</Tab>
<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,
]}
onVolumeChange={onVolumeChange}
sources={[
{ 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
/>
</Tab>
</Tabs>
<h1 className="mb-3 mt-3">
{title}
:
{' '}
{videoTitle}
</h1>
<Row className="mb-3">
<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>
)
: (
''
)
}
</ButtonGroup>
</Col>
<Col sm={12} md={5}>
<CopyField icon="share">
{[basePath, id, vslug].join('/')}
</CopyField>
</Col>
</Row>
</div >
);
}