-
- [
- fileName
- ? getThumbnailURL(thumbnailServerURL, id, fileName, 90 * 1000, {
- width,
- })
- : hourglassImage,
- `${width}w`,
- ])
- .map((item) => item.join(' '))
- .join(', ')}
- alt={title}
- />
-
- {title}
-
- {!fileName ? (
-
-
- {' '}
- Coming up
-
- ) : ''}
- {displayDuration !== null ? (
-
-
- {' '}
-
-
- ) : ''}
-
-
-
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+ 0
+ ? getThumbnailURL(thumbnailServerURL, id, thumbnails[0].fileName)
+ : hourglassImage
+ }
+ srcSet={
+ Array.isArray(thumbnails) && thumbnails.length > 0
+ ? thumbnails
+ .map(({
+ sourceSize,
+ fileName,
+ }) => (
+ [
+ thumbnails.length > 0
+ ? getThumbnailURL(thumbnailServerURL, id, fileName)
+ : hourglassImage,
+ sourceSize,
+ ]
+ ))
+ .map((item) => item.join(' '))
+ .join(', ')
+ : ''
+ }
+ alt={title}
+ />
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+
+
{title}
+
{runData?.category}
+
+
+
+
+ {runners && runners.length > 0 ? (
+
+
+ {' '}
+ {runners.reduce((all: Array, runner: RunnerData) => [
+ ...all,
+ all.length > 0 ? ' / ' : null,
+ ,
+ ], [])}
+
+ ) : ''}
+ {displayDuration !== null ? (
+
+
+ {' '}
+
+
+ ) : (
+
+
+ {' '}
+ Coming up
+
+ )}
+
+
);
- if (fileName) {
- return (
-
- {listGroupItem}
-
- );
- }
return listGroupItem;
}
diff --git a/frontend/components/VideoPlayer.tsx b/frontend/components/VideoPlayer.tsx
index 786d8d7..b10383a 100644
--- a/frontend/components/VideoPlayer.tsx
+++ b/frontend/components/VideoPlayer.tsx
@@ -16,13 +16,13 @@
*/
import * as React from 'react';
+import { Ratio } from 'react-bootstrap';
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js';
import 'videojs-errors';
// TODO - localization
// import 'videojs-errors/dist/lang/de';
// import 'videojs-errors/dist/lang/en';
// import 'videojs-contrib-dash';
-import { ResponsiveEmbed } from 'react-bootstrap';
export default class VideoPlayer extends React.Component<{
onReady?: () => void,
@@ -64,44 +64,40 @@ export default class VideoPlayer extends React.Component<{
...videoJsOptions
} = this.props;
return (
-
-
-
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
-
-
-
-
+
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
+
+
);
}
}
diff --git a/frontend/components/localization/FormattedMessage.tsx b/frontend/components/localization/FormattedMessage.tsx
index e53313b..c9cc4b6 100644
--- a/frontend/components/localization/FormattedMessage.tsx
+++ b/frontend/components/localization/FormattedMessage.tsx
@@ -15,8 +15,6 @@
* along with this program. If not, see .
*/
-import React from 'react';
-import { Spinner } from 'react-bootstrap';
import { FormattedMessage } from 'react-intl';
import WrapReactIntl from './WrapReactIntl';
diff --git a/frontend/components/localization/WrapReactIntl.tsx b/frontend/components/localization/WrapReactIntl.tsx
index 2623d7c..9fe56ec 100644
--- a/frontend/components/localization/WrapReactIntl.tsx
+++ b/frontend/components/localization/WrapReactIntl.tsx
@@ -20,7 +20,7 @@ import { Spinner } from 'react-bootstrap';
import { isPolyfillPhaseDone } from 'util/localization';
export default function WrapReactIntl(Component: React.ComponentType
) {
- return (props: P) => {
+ return function (props: P) {
if (!isPolyfillPhaseDone()) {
return (
- *
+ *
* 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 .
*/
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
index 7b7aa2c..4f11a03 100644
--- a/frontend/next-env.d.ts
+++ b/frontend/next-env.d.ts
@@ -1,2 +1,5 @@
///
-///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
new file mode 100644
index 0000000..c0a962b
--- /dev/null
+++ b/frontend/next.config.mjs
@@ -0,0 +1,53 @@
+// @ts-check
+
+/**
+ * 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 withPlugins from 'next-compose-plugins';
+// import optimizedImages from 'next-optimized-images';
+
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {
+ reactStrictMode: false,
+ compiler: {
+ styledComponents: true,
+ },
+};
+
+// const nextConfig = withPlugins(
+// [
+// /*[
+// optimizedImages,
+// {
+// // config for next-optimized-images
+// inlineImageLimit: -1,
+// },
+// ],*/
+
+// // your other plugins here
+// ],
+// {
+// /*images: {
+// // This is set due to next-optimized-images, see // See https://github.com/cyrilwanner/next-optimized-images/issues/251#issuecomment-867250968
+// disableStaticImages: true,
+// },*/
+// }
+// );
+
+export default nextConfig;
diff --git a/frontend/package.json b/frontend/package.json
index 5014a8a..aa7ede3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,5 @@
{
"name": "gdq-archive-frontend",
- "version": "0.1.0",
"private": true,
"scripts": {
"dev": "run-s i18n:build dev:next",
@@ -15,67 +14,67 @@
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
- "@forevolve/bootstrap-dark": "^1.0.0-alpha.1091",
- "@formatjs/intl-numberformat": "^5.5.4",
- "@formatjs/intl-utils": "^3.8.3",
- "@fortawesome/fontawesome-free": "^5.14.0",
- "@fortawesome/fontawesome-svg-core": "^1.2.30",
- "@fortawesome/free-brands-svg-icons": "^5.14.0",
- "@fortawesome/free-regular-svg-icons": "^5.14.0",
- "@fortawesome/free-solid-svg-icons": "^5.14.0",
- "@fortawesome/react-fontawesome": "^0.1.11",
- "bootstrap": "^4.5.2",
- "cssnano": "^4.1.10",
- "file-loader": "^6.0.0",
- "fuse.js": "^6.4.1",
- "imagemin-optipng": "^8.0.0",
- "imagemin-svgo": "^8.0.0",
- "intl-messageformat": ">= 5.1",
- "intl-messageformat-parser": "^6.0.1",
- "jquery": "~3",
- "native-url": "^0.3.4",
- "next": "9.5.2",
- "next-compose-plugins": "^2.2.0",
- "next-iron-session": "^4.1.8",
- "next-optimized-images": "^2.6.2",
+ "@formatjs/intl-numberformat": "^7.1.5",
+ "@formatjs/intl-utils": "^3.8.4",
+ "@fortawesome/fontawesome-free": "^6.2.1",
+ "@fortawesome/fontawesome-svg-core": "^6.2.1",
+ "@fortawesome/free-brands-svg-icons": "^6.2.1",
+ "@fortawesome/free-regular-svg-icons": "^6.2.1",
+ "@fortawesome/free-solid-svg-icons": "^6.2.1",
+ "@fortawesome/react-fontawesome": "^0.2.0",
+ "@popperjs/core": "^2.9.2",
+ "bootstrap": "5.1.x",
+ "bootstrap-dark-5": "1.1.0",
+ "cssnano": "^5.0.6",
+ "eslint-config-next": "^13.1.1",
+ "fuse.js": "^6.4.6",
+ "imagemin-svgo": "^9.0.0",
+ "intl-messageformat": ">= 2.0",
+ "next": "^13.1.1",
+ "next-compose-plugins": "^2.2.1",
+ "next-iron-session": "^4.2.0",
"nprogress": "^0.2.0",
"popper.js": "^1.16.1",
- "raw-loader": "^4.0.1",
- "react": "16.13.1",
- "react-bootstrap": "^1.3.0",
- "react-dom": "16.13.1",
- "react-intl": "^5.6.3",
+ "react": "^18.2.0",
+ "react-bootstrap": "^2.4.0",
+ "react-dom": "^18.2.0",
+ "react-intl": "^6.2.5",
"react-intl-formatted-duration": "^4.0.0",
- "sass": "^1.26.10",
- "shaka-player": "^3.0.3",
- "shaka-player-react": "^1.0.1",
- "swr": "^0.3.0",
- "url-loader": "^4.1.0",
- "url-slug": "^2.3.2",
- "video.js": "^7.8.4",
- "videojs-contrib-dash": "^2.11.0",
- "videojs-errors": "^4.3.2",
- "webpack": "^4.0.0",
- "xmlbuilder2": "^2.3.1"
+ "react-youtube": "^9.0.2",
+ "sass": "^1.53.0",
+ "shaka-player": "^3.1.1",
+ "shaka-player-react": "^1.1.5",
+ "sharp": "^0.31.3",
+ "swr": "^2.0.0",
+ "url-slug": "^3.0.2",
+ "util-deprecate": "^1.0.2",
+ "video.js": "^7.13.3",
+ "videojs-contrib-dash": "^5.0.0",
+ "videojs-errors": "^4.5.0",
+ "xmlbuilder2": "^2.4.1"
},
"devDependencies": {
- "@formatjs/cli": "^2.7.5",
- "@types/node": "^14.6.0",
+ "@formatjs/cli": "^4.2.27",
+ "@types/node": "^16.3.1",
"@types/nprogress": "^0.2.0",
- "@types/react": "^16.9.46",
- "@typescript-eslint/eslint-plugin": "^3.9.1",
- "@typescript-eslint/parser": "^3.9.1",
- "eslint": "^7.7.0",
- "eslint-config-airbnb-typescript": "^9.0.0",
- "eslint-plugin-import": "2.21.2",
- "eslint-plugin-jsx-a11y": "6.3.0",
- "eslint-plugin-react": "7.20.0",
- "eslint-plugin-react-hooks": "4",
- "lint-staged": "^10.2.11",
+ "@types/react": "^18.0.26",
+ "@types/react-dom": "^18.0.10",
+ "@types/util-deprecate": "^1.0.0",
+ "@typescript-eslint/eslint-plugin": "^5.13.0",
+ "@typescript-eslint/parser": "^5.0.0",
+ "eslint": "^7.32.0 || ^8.2.0",
+ "eslint-config-airbnb": "^19.0.4",
+ "eslint-config-airbnb-typescript": "^17.0.0",
+ "eslint-plugin-import": "^2.25.3",
+ "eslint-plugin-jsx-a11y": "^6.5.1",
+ "eslint-plugin-react": "^7.28.0",
+ "eslint-plugin-react-hooks": "^4.3.0",
+ "lint-staged": "^11.0.0",
"npm-run-all": "^4.1.5",
- "postcss-flexbugs-fixes": "^4.2.1",
- "postcss-preset-env": "^6.7.0",
- "typescript": "^4.0.2"
+ "postcss": "^8.2.15",
+ "postcss-flexbugs-fixes": "^5.0.2",
+ "postcss-preset-env": "^7.2.0",
+ "typescript": "^4.3.5"
},
"husky": {
"hooks": {
@@ -93,6 +92,8 @@
"es2020": true
},
"extends": [
+ "plugin:@next/next/recommended",
+ "airbnb",
"airbnb-typescript"
],
"parserOptions": {
diff --git a/frontend/next.config.js b/frontend/pages/404.tsx
similarity index 64%
rename from frontend/next.config.js
rename to frontend/pages/404.tsx
index 5214326..71cb708 100644
--- a/frontend/next.config.js
+++ b/frontend/pages/404.tsx
@@ -1,29 +1,24 @@
/**
- * Copyright (C) 2019-2021 Carl Kittelberger
- *
+ * 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 .
*/
-const withPlugins = require('next-compose-plugins');
-const optimizedImages = require('next-optimized-images');
+import * as React from 'react';
+import Error from './_error';
-module.exports = withPlugins([
- [optimizedImages, {
- /* config for next-optimized-images */
- inlineImageLimit: -1,
- }],
-
- // your other plugins here
-
-]);
+function Error404Page() {
+ return ;
+}
+export default Error404Page;
diff --git a/frontend/pages/[id].tsx b/frontend/pages/[id].tsx
index 454d342..0d8c0cc 100644
--- a/frontend/pages/[id].tsx
+++ b/frontend/pages/[id].tsx
@@ -23,15 +23,16 @@ import Head from 'next/head';
import Link from 'next/link';
import { useIntl } from 'react-intl';
+import { GetServerSideProps, NextPage } from 'next';
+import { RunnerList } from 'util/datatypes/RunnerList';
import { FormattedMessage } from '../components/localization';
import RelativeTime from '../components/RelativeTime';
import VideoList from '../components/VideoList';
import { notFound } from '../util/status';
-import { getIndex, getVideos } from '../util/api';
+import { getIndex, getRunners, getVideos } from '../util/api';
import { VideoEntry } from '../util/datatypes/VideoList';
-import { GetServerSideProps, NextPage } from 'next';
interface VideoListPageProps {
id?: string,
@@ -39,15 +40,20 @@ interface VideoListPageProps {
thumbnailServerURL?: string,
title?: string,
videos?: Array,
-};
+ runners?: RunnerList,
+}
-export const getServerSideProps: GetServerSideProps = async ({ params: { id } }) => {
+export const getServerSideProps: GetServerSideProps = async ({
+ params: { id },
+}) => {
// Fetch URL to thumbnails server
const {
ids,
servers: { thumbnails: thumbnailServerURL },
} = await getIndex();
+ const runners = await getRunners();
+
const vodMeta = ids.find(({
id: thisID,
}: {
@@ -85,17 +91,19 @@ export const getServerSideProps: GetServerSideProps = async
title,
videos: finalVideos,
lastUpdatedAt,
+ runners,
},
};
-}
+};
-const VideoListPage: NextPage = ({
+const VideoListPage: NextPage = function VideoListPage({
id,
lastUpdatedAt,
thumbnailServerURL,
title,
videos,
-}) => {
+ runners,
+}) {
if (!id) {
return notFound();
}
@@ -111,21 +119,16 @@ const VideoListPage: NextPage = ({
- {title}
- {' '}
- –
- {' '}
- {intl.formatMessage({
+ {`${title} – ${intl.formatMessage({
id: 'App.title',
description: 'The full title of the website',
defaultMessage: 'Games Done Quick Instant Archive',
- })}
+ })}`}
-
-
+
= ({
/>
-
+
{title}
@@ -143,6 +146,7 @@ const VideoListPage: NextPage = ({
@@ -169,6 +173,6 @@ const VideoListPage: NextPage = ({
}
);
-}
+};
export default VideoListPage;
diff --git a/frontend/pages/[id]/[vslug].tsx b/frontend/pages/[id]/[vslug].tsx
index 0279d98..b6cdd02 100644
--- a/frontend/pages/[id]/[vslug].tsx
+++ b/frontend/pages/[id]/[vslug].tsx
@@ -22,23 +22,24 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import {
- Breadcrumb, Button, ButtonGroup, Col, ResponsiveEmbed, Row, Tab, Tabs,
+ 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 DownloadButton from 'components/DownloadButton';
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 sanitizeTitle from '../../util/sanitizeTitle';
import { notFound } from '../../util/status';
import {
getDownloadURL,
@@ -46,8 +47,9 @@ import {
getVideos,
submitPreferences,
} from '../../util/api';
+import DownloadButton from '../../components/DownloadButton';
-interface VideoPlayerPageParameters {
+interface VideoPlayerPageParameters extends ParsedUrlQuery {
id: string,
vslug: string,
}
@@ -55,7 +57,7 @@ interface VideoPlayerPageParameters {
interface VideoPlayerPageProps {
id?: string,
vslug?: string,
- video?: number,
+ video?: number | VideoEntry,
volume?: number,
redirect?: boolean,
title?: string,
@@ -65,7 +67,14 @@ interface VideoPlayerPageProps {
twitchPlayerParentKey?: string,
}
-const getProps = withSession(async (req, _res, { id, vslug }: VideoPlayerPageParameters): Promise> => {
+const getProps = withSession(async (
+ req,
+ _res,
+ {
+ id,
+ vslug,
+ }: VideoPlayerPageParameters,
+): Promise> => {
if (typeof id !== 'string') {
throw new Error('only expected a single id');
}
@@ -123,7 +132,7 @@ const getProps = withSession(async (req, _res, { id, vslug }: VideoPlayerPagePar
// 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,
+ (video: VideoEntry) => video.downloadFileName === sanitizedFileName,
);
if (realVIndex >= 0) {
const video = videos[realVIndex];
@@ -165,7 +174,7 @@ const getProps = withSession(async (req, _res, { id, vslug }: VideoPlayerPagePar
}
if (!req.headers.host) {
- throw new Error(JSON.stringify(req.headers))
+ throw new Error(JSON.stringify(req.headers));
}
// Pass data to the page via props
@@ -184,7 +193,9 @@ const getProps = withSession(async (req, _res, { id, vslug }: VideoPlayerPagePar
};
});
-export const getServerSideProps: GetServerSideProps = async ({
+type ServerSideProps = GetServerSideProps;
+
+export const getServerSideProps: ServerSideProps = async ({
req,
res,
params,
@@ -230,13 +241,28 @@ export default function VideoPlayerPage({
const playerRef = React.useRef();
+ if (typeof video === 'number') {
+ throw new Error('Logic error - video should not be a number');
+ }
+
const {
- fileName,
+ downloadFileName,
title: videoTitle,
+ sourceVideoID,
sourceVideoURL,
- sourceVideoStart,
+ 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() {
@@ -254,34 +280,170 @@ export default function VideoPlayerPage({
}
}
- 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`)
+ 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(
+
+
+
+
+ ,
+ );
+ defaultTab = 'twitch-player';
+ const twitchWatchURL = new URL(sourceVideoURL);
+ twitchWatchURL.searchParams.append('t', `${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60}s`);
+ downloadButtons.push(
+ ,
+ );
+ }
+
+ // YouTube tab
+ if (youtubeVideoID) {
+ const youtubeURL = new URL(youtubeVideoID, 'https://youtu.be/');
+ tabs.unshift(
+
+
+ ,
+ );
+ defaultTab = 'youtube-player';
+ downloadButtons.push(
+ ,
+ );
+ }
return (
- {videoTitle}
- {' '}
- –
- {' '}
- {title}
- {' '}
- –
- {' '}
- {intl.formatMessage({
+ {`${videoTitle} – ${title} – ${intl.formatMessage({
id: 'App.title',
description: 'The full title of the website',
defaultMessage: 'Games Done Quick Instant Archive',
- })}
+ })}`}
-
-
+
-
+
{title}
-
+
{videoTitle}
-
-
-
-
-
-
-
-
+ {tabs}
-
+
{title}
:
{' '}
{videoTitle}
+
+ {run?.category}
+
+
- {/* */}
- {
- sourceVideoURL
- ? (
-
- )
- : (
- ''
- )
- }
+ {downloadButtons}
@@ -398,6 +490,6 @@ export default function VideoPlayerPage({
-
+