/** * Copyright (C) 2019-2021 Carl Kittelberger * * 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 . */ 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> => { 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 = async ({ req, res, params, }) => getProps(req, res, params); export default function VideoPlayerPage({ id, vslug, video, volume, // extra, redirect, title, hlsServerURL, dashServerURL, basePath, twitchPlayerParentKey, }: InferGetServerSidePropsType) { if (redirect) { const router = useRouter(); React.useEffect(() => { router.push(`/${id}/${vslug}`); }); return (

You will be redirected {' '} here

); } if (!video) { return notFound(); } const intl = useIntl(); const playerRef = React.useRef(); 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> = []; let defaultTab = 'mirror-player'; const downloadButtons: Array = []; // Mirror player tab if (typeof downloadFileName === 'string' && downloadFileName.length > 0) { tabs.push( , ); downloadButtons.push(); } // 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(