From d98768bc58f419803e84bb0ac5f83d0d123a8a49 Mon Sep 17 00:00:00 2001 From: Janne Koschinski <janne@kuschku.de> Date: Sat, 26 Sep 2020 23:30:22 +0200 Subject: [PATCH] Implement basic seeking --- package-lock.json | 107 +++++++++++ package.json | 4 + src/App.tsx | 100 ++--------- src/api/ApiClient.ts | 5 +- src/api/models/Rating.ts | 2 +- src/api/models/Title.ts | 13 +- src/api/models/TitleDescription.ts | 2 +- src/api/models/TitleName.ts | 2 +- src/api/models/dto/TitleMeta.ts | 7 + src/index.tsx | 2 +- src/routes/TitleRoute.tsx | 21 +++ src/routes/main/MainPage.tsx | 79 ++++++++ src/routes/player/Player.tsx | 63 +++++++ src/routes/player/PlayerError.tsx | 7 + src/routes/player/PlayerLoading.tsx | 7 + src/routes/player/PlayerPage.tsx | 23 +++ src/routes/player/PreviewViewer.tsx | 23 +++ src/routes/player/SeekBar.tsx | 87 +++++++++ src/util/CurrentTitleContext.ts | 7 + src/{ => util}/locale/Locale.ts | 0 src/{ => util}/locale/LocalePriority.ts | 0 src/util/locale/LocalizedContext.ts | 10 ++ src/{ => util}/locale/LocalizedData.ts | 0 src/{ => util}/locale/RegionalData.ts | 0 src/{ => util}/locale/selectLocaleData.ts | 0 src/{ => util}/locale/selectRegionalData.ts | 0 src/util/media/MediaFragment.ts | 189 ++++++++++++++++++++ src/util/media/useCanvas.ts | 16 ++ src/util/media/useDuration.ts | 23 +++ src/util/media/useImage.ts | 18 ++ src/util/media/usePosition.ts | 19 ++ src/util/media/useTextTrackCues.ts | 22 +++ src/{ => util}/mime/PlayabilityRating.ts | 0 src/{ => util}/mime/usePlayabilityRating.ts | 10 +- src/{ => util}/mime/videoMimeString.ts | 2 +- src/{ => util}/request/RequestClient.ts | 0 src/{ => util}/request/RequestError.ts | 0 src/{ => util}/request/RequestErrorKind.ts | 0 src/util/{ => sort}/sortLexically.ts | 0 src/util/{ => sort}/sortNumerically.ts | 0 src/util/sprite/ImageSprite.tsx | 9 + src/util/sprite/parseImageSprite.tsx | 27 +++ src/util/sprite/useImageSprite.ts | 23 +++ 43 files changed, 834 insertions(+), 95 deletions(-) create mode 100644 src/api/models/dto/TitleMeta.ts create mode 100644 src/routes/TitleRoute.tsx create mode 100644 src/routes/main/MainPage.tsx create mode 100644 src/routes/player/Player.tsx create mode 100644 src/routes/player/PlayerError.tsx create mode 100644 src/routes/player/PlayerLoading.tsx create mode 100644 src/routes/player/PlayerPage.tsx create mode 100644 src/routes/player/PreviewViewer.tsx create mode 100644 src/routes/player/SeekBar.tsx create mode 100644 src/util/CurrentTitleContext.ts rename src/{ => util}/locale/Locale.ts (100%) rename src/{ => util}/locale/LocalePriority.ts (100%) create mode 100644 src/util/locale/LocalizedContext.ts rename src/{ => util}/locale/LocalizedData.ts (100%) rename src/{ => util}/locale/RegionalData.ts (100%) rename src/{ => util}/locale/selectLocaleData.ts (100%) rename src/{ => util}/locale/selectRegionalData.ts (100%) create mode 100644 src/util/media/MediaFragment.ts create mode 100644 src/util/media/useCanvas.ts create mode 100644 src/util/media/useDuration.ts create mode 100644 src/util/media/useImage.ts create mode 100644 src/util/media/usePosition.ts create mode 100644 src/util/media/useTextTrackCues.ts rename src/{ => util}/mime/PlayabilityRating.ts (100%) rename src/{ => util}/mime/usePlayabilityRating.ts (64%) rename src/{ => util}/mime/videoMimeString.ts (70%) rename src/{ => util}/request/RequestClient.ts (100%) rename src/{ => util}/request/RequestError.ts (100%) rename src/{ => util}/request/RequestErrorKind.ts (100%) rename src/util/{ => sort}/sortLexically.ts (100%) rename src/util/{ => sort}/sortNumerically.ts (100%) create mode 100644 src/util/sprite/ImageSprite.tsx create mode 100644 src/util/sprite/parseImageSprite.tsx create mode 100644 src/util/sprite/useImageSprite.ts diff --git a/package-lock.json b/package-lock.json index 464c672..fc55f1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1713,6 +1713,11 @@ "@types/node": "*" } }, + "@types/history": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz", + "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==" + }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -1790,6 +1795,25 @@ "@types/react": "*" } }, + "@types/react-router": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.8.tgz", + "integrity": "sha512-HzOyJb+wFmyEhyfp4D4NYrumi+LQgQL/68HvJO+q6XtuHSDvw6Aqov7sCAhjbNq3bUPgPqbdvjXC5HeB2oEAPg==", + "requires": { + "@types/history": "*", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.5.tgz", + "integrity": "sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw==", + "requires": { + "@types/history": "*", + "@types/react": "*", + "@types/react-router": "*" + } + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -6387,6 +6411,19 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -8455,6 +8492,15 @@ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" }, + "mini-create-react-context": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz", + "integrity": "sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA==", + "requires": { + "@babel/runtime": "^7.5.5", + "tiny-warning": "^1.0.3" + } + }, "mini-css-extract-plugin": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz", @@ -10957,6 +11003,52 @@ "tiny-warning": "^1.0.2" } }, + "react-router": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", + "integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + } + } + }, + "react-router-dom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", + "integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", + "requires": { + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.2.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, "react-scripts": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.4.3.tgz", @@ -11344,6 +11436,11 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=" }, + "resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -12863,6 +12960,11 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "tiny-invariant": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + }, "tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -13264,6 +13366,11 @@ "spdx-expression-parse": "^3.0.0" } }, + "value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index fb23f0c..cc1d043 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,14 @@ "@types/node": "^12.12.62", "@types/react": "^16.9.49", "@types/react-dom": "^16.9.8", + "@types/react-router": "^5.1.8", + "@types/react-router-dom": "^5.1.5", "immer": "^7.0.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-jss": "^10.4.0", + "react-router": "^5.2.0", + "react-router-dom": "^5.2.0", "react-scripts": "3.4.3", "react-sweet-state": "^2.3.1", "typescript": "^3.7.5" diff --git a/src/App.tsx b/src/App.tsx index 14fdcf3..b61c403 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,86 +1,20 @@ -import React, {useEffect, useState} from 'react'; -import {createUseStyles} from "react-jss"; -import {useApiClient} from "./api/ApiClientContext"; -import {getLocalizedDescription, getLocalizedName, getLocalizedRating, Title} from "./api/models/Title"; -import {Locale} from "./locale/Locale"; -import {usePlayabilityRating} from "./mime/usePlayabilityRating"; -import {sortLexicallyAsc} from "./util/sortLexically"; -import {sortNumericallyDesc} from "./util/sortNumerically"; - -export default function App() { - const {apiClient} = useApiClient(); - const playabilityRating = usePlayabilityRating(); - const [data, setData] = useState<Title[]>(); - useEffect(() => { - apiClient.listTitles().then(setData); - }, [apiClient]); - const locale: Locale = { - language: "en", - region: "DE", - }; - - const classes = useStyles(); +import React from 'react'; +import {BrowserRouter, Route} from 'react-router-dom'; +import {MainPage} from "./routes/main/MainPage"; +import {PlayerPage} from "./routes/player/PlayerPage"; +import {TitleRoute} from "./routes/TitleRoute"; +export function App() { return ( - <div> - {data?.sort(sortLexicallyAsc(item => getLocalizedName(item, locale)?.name || "")) - .map((item: Title) => { - const title = getLocalizedName(item, locale); - const description = getLocalizedDescription(item, locale); - const rating = getLocalizedRating(item, locale); - const poster = item.images.find(it => it.kind === "poster"); - const backdrop = item.images.find(it => it.kind === "backdrop"); - const mediaList = item.media.map(it => { - return { - item: it, - value: playabilityRating(it), - }; - }).sort(sortNumericallyDesc(it => it.value)); - const media = mediaList[0]?.item; - - return ( - <div key={item.ids.uuid} className={classes.movie}> - <h1>{title?.name}</h1> - <p><strong>{description?.tagline}</strong></p> - <p>{description?.overview}</p> - <p><strong>{rating?.certification}</strong></p> - {poster && ( - <img - className={classes.poster} - alt={`Movie poster for ${title?.name}`} - src={poster.src} - /> - )} - {backdrop && ( - <img - className={classes.poster} - alt={`Movie backdrop for ${title?.name}`} - src={backdrop.src} - /> - )} - {media && ( - <p><a href={media.src}>{media.src}</a></p> - )} - {item.preview && ( - <p><a href={item.preview}>{item.preview}</a></p> - )} - </div> - ) - })} - </div> - ); + <BrowserRouter> + <Route path="/player/:titleId" exact> + <TitleRoute> + <PlayerPage/> + </TitleRoute> + </Route> + <Route path="/" exact> + <MainPage/> + </Route> + </BrowserRouter> + ) } - -const useStyles = createUseStyles({ - movie: { - maxWidth: "40rem", - margin: { - left: "auto", - right: "auto", - } - }, - poster: { - maxWidth: "20rem", - maxHeight: "20rem", - } -}); diff --git a/src/api/ApiClient.ts b/src/api/ApiClient.ts index 065f352..91d3e36 100644 --- a/src/api/ApiClient.ts +++ b/src/api/ApiClient.ts @@ -1,8 +1,9 @@ +import {RequestClient} from "../util/request/RequestClient"; import {GenreWithTitles} from "./models/dto/GenreWithTitles"; +import {TitleMeta} from "./models/dto/TitleMeta"; import {Episode} from "./models/Episode"; import {Genre} from "./models/Genre"; import {Title} from "./models/Title"; -import {RequestClient} from "../request/RequestClient"; export class ApiClient extends RequestClient { public async listGenres(): Promise<Genre[]> { @@ -23,7 +24,7 @@ export class ApiClient extends RequestClient { }); } - public async getTitle(titleId: string): Promise<Title> { + public async getTitle(titleId: string): Promise<TitleMeta> { return await this.request(`api/v1/titles/${titleId}`, { method: "GET" }); diff --git a/src/api/models/Rating.ts b/src/api/models/Rating.ts index 898cfa1..716d9f5 100644 --- a/src/api/models/Rating.ts +++ b/src/api/models/Rating.ts @@ -1,4 +1,4 @@ -import {LocalizedData} from "../../locale/LocalizedData"; +import {LocalizedData} from "../../util/locale/LocalizedData"; export interface Rating extends LocalizedData { region: string | null, diff --git a/src/api/models/Title.ts b/src/api/models/Title.ts index 0b0fb82..5e0caec 100644 --- a/src/api/models/Title.ts +++ b/src/api/models/Title.ts @@ -1,7 +1,9 @@ -import {Locale} from "../../locale/Locale"; -import {LocalePriority} from "../../locale/LocalePriority"; -import {selectLocaleVersion} from "../../locale/selectLocaleData"; -import {selectRegionalVersion} from "../../locale/selectRegionalData"; +import {Locale} from "../../util/locale/Locale"; +import {LocalePriority} from "../../util/locale/LocalePriority"; +import {selectLocaleVersion} from "../../util/locale/selectLocaleData"; +import {selectRegionalVersion} from "../../util/locale/selectRegionalData"; +import {PlayabilityRating} from "../../util/mime/PlayabilityRating"; +import {selectPlayabileMedia} from "../../util/mime/usePlayabilityRating"; import {Cast} from "./Cast"; import {Genre} from "./Genre"; import {Image} from "./Image"; @@ -41,3 +43,6 @@ export const getLocalizedDescription = (title: Title, locale: Locale): TitleDesc export const getLocalizedRating = (title: Title, locale: Locale): Rating | null => selectRegionalVersion(locale, title.ratings) + +export const getPlayableMedia = (title: Title, playabilityRating: (media: Media) => PlayabilityRating): Media | null => + selectPlayabileMedia(playabilityRating, title.media) diff --git a/src/api/models/TitleDescription.ts b/src/api/models/TitleDescription.ts index a9c5665..3a8a94f 100644 --- a/src/api/models/TitleDescription.ts +++ b/src/api/models/TitleDescription.ts @@ -1,4 +1,4 @@ -import {LocalizedData} from "../../locale/LocalizedData"; +import {LocalizedData} from "../../util/locale/LocalizedData"; export interface TitleDescription extends LocalizedData { overview: string, diff --git a/src/api/models/TitleName.ts b/src/api/models/TitleName.ts index 9299eaf..98f66b5 100644 --- a/src/api/models/TitleName.ts +++ b/src/api/models/TitleName.ts @@ -1,4 +1,4 @@ -import {LocalizedData} from "../../locale/LocalizedData"; +import {LocalizedData} from "../../util/locale/LocalizedData"; export interface TitleName extends LocalizedData { name: string, diff --git a/src/api/models/dto/TitleMeta.ts b/src/api/models/dto/TitleMeta.ts new file mode 100644 index 0000000..d81fb59 --- /dev/null +++ b/src/api/models/dto/TitleMeta.ts @@ -0,0 +1,7 @@ +import {Title} from "../Title"; + +export interface TitleMeta { + title: Title, + show: Title | null, + episodes: Title[], +} diff --git a/src/index.tsx b/src/index.tsx index 08d5040..6c9be75 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {defaults} from 'react-sweet-state'; -import App from './App'; +import {App} from "./App"; import * as serviceWorker from './serviceWorker'; defaults.devtools = process.env.NODE_ENV !== "production"; diff --git a/src/routes/TitleRoute.tsx b/src/routes/TitleRoute.tsx new file mode 100644 index 0000000..ccbaf91 --- /dev/null +++ b/src/routes/TitleRoute.tsx @@ -0,0 +1,21 @@ +import React, {PropsWithChildren, useEffect, useState} from "react"; +import {useParams} from "react-router"; +import {useApiClient} from "../api/ApiClientContext"; +import {TitleMeta} from "../api/models/dto/TitleMeta"; +import {CurrentTitleProvider} from "../util/CurrentTitleContext"; + +export function TitleRoute(props: PropsWithChildren<{}>) { + const {children} = props; + const {titleId} = useParams<{ titleId: string }>(); + const {apiClient} = useApiClient(); + const [meta, setMeta] = useState<TitleMeta | null>(null); + useEffect(() => { + apiClient.getTitle(titleId).then(setMeta); + }, [apiClient, titleId, setMeta]); + + return ( + <CurrentTitleProvider value={meta}> + {children} + </CurrentTitleProvider> + ) +} diff --git a/src/routes/main/MainPage.tsx b/src/routes/main/MainPage.tsx new file mode 100644 index 0000000..c682be4 --- /dev/null +++ b/src/routes/main/MainPage.tsx @@ -0,0 +1,79 @@ +import React, {useEffect, useState} from "react"; +import {createUseStyles} from "react-jss"; +import {Link} from "react-router-dom"; +import {useApiClient} from "../../api/ApiClientContext"; +import {getLocalizedDescription, getLocalizedName, getLocalizedRating, Title} from "../../api/models/Title"; +import {Locale} from "../../util/locale/Locale"; +import {sortLexicallyAsc} from "../../util/sort/sortLexically"; + +interface Props { + +} + +export function MainPage(props: Props) { + const {apiClient} = useApiClient(); + const [data, setData] = useState<Title[]>(); + useEffect(() => { + apiClient.listTitles().then(setData); + }, [apiClient]); + const locale: Locale = { + language: "en", + region: "DE", + }; + + const classes = useStyles(); + + return ( + <div> + {data?.sort(sortLexicallyAsc(item => getLocalizedName(item, locale)?.name || "")) + .map((item: Title) => { + const title = getLocalizedName(item, locale); + const description = getLocalizedDescription(item, locale); + const rating = getLocalizedRating(item, locale); + const poster = item.images.find(it => it.kind === "poster"); + const backdrop = item.images.find(it => it.kind === "backdrop"); + + return ( + <div key={item.ids.uuid} className={classes.movie}> + <h1>{title?.name}</h1> + <p><strong>{description?.tagline}</strong></p> + <p>{description?.overview}</p> + <p><strong>{rating?.certification}</strong></p> + {poster && ( + <img + className={classes.poster} + alt={`Movie poster for ${title?.name}`} + src={poster.src} + /> + )} + {backdrop && ( + <img + className={classes.poster} + alt={`Movie backdrop for ${title?.name}`} + src={backdrop.src} + /> + )} + {item.preview && ( + <p><a href={item.preview}>{item.preview}</a></p> + )} + <Link to={"/player/" + item.ids.uuid}>Play</Link> + </div> + ) + })} + </div> + ); +} + +const useStyles = createUseStyles({ + movie: { + maxWidth: "40rem", + margin: { + left: "auto", + right: "auto", + } + }, + poster: { + maxWidth: "20rem", + maxHeight: "20rem", + } +}); diff --git a/src/routes/player/Player.tsx b/src/routes/player/Player.tsx new file mode 100644 index 0000000..5846192 --- /dev/null +++ b/src/routes/player/Player.tsx @@ -0,0 +1,63 @@ +import React, {useMemo, useState} from "react"; +import {createUseStyles} from "react-jss"; +import {TitleMeta} from "../../api/models/dto/TitleMeta"; +import {Media} from "../../api/models/Media"; +import {getLocalizedDescription, getLocalizedName, getLocalizedRating} from "../../api/models/Title"; +import {useLocale} from "../../util/locale/LocalizedContext"; +import {useDuration} from "../../util/media/useDuration"; +import {usePosition} from "../../util/media/usePosition"; +import {SeekBar} from "./SeekBar"; + +interface Props { + title: TitleMeta, + media: Media, +} + +export function Player(props: Props) { + const {media} = props; + const {title, show, episodes} = props.title; + + const classes = useStyles(); + const locale = useLocale(); + + const name = getLocalizedName(title, locale); + const description = getLocalizedDescription(title, locale); + const rating = getLocalizedRating(title, locale); + + const [previewTrackElement, setPreviewTrackElement] = useState<HTMLTrackElement | null>(null); + const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null); + + const duration = useDuration(videoElement); + const position = usePosition(videoElement); + + const video = useMemo(() => ( + <video ref={setVideoElement} src={media.src} className={classes.player} crossOrigin="anonymous" controls> + {title.preview && ( + <track ref={setPreviewTrackElement} kind="metadata" label="previews" src={title.preview}/> + )} + </video> + ), [classes.player, media.src, title.preview]); + + return ( + <div> + <p>{name?.name}</p> + {video} + <p>{position}:{duration}</p> + <SeekBar + video={videoElement} + previewTrack={previewTrackElement} + duration={duration} + /> + </div> + ); +} + +const useStyles = createUseStyles({ + player: { + maxWidth: "40rem", + margin: { + left: "auto", + right: "auto", + } + } +}); diff --git a/src/routes/player/PlayerError.tsx b/src/routes/player/PlayerError.tsx new file mode 100644 index 0000000..6cec388 --- /dev/null +++ b/src/routes/player/PlayerError.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +export function PlayerError() { + return ( + <div>Error</div> + ); +} diff --git a/src/routes/player/PlayerLoading.tsx b/src/routes/player/PlayerLoading.tsx new file mode 100644 index 0000000..ae71f1f --- /dev/null +++ b/src/routes/player/PlayerLoading.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +export function PlayerLoading() { + return ( + <div>Loading</div> + ); +} diff --git a/src/routes/player/PlayerPage.tsx b/src/routes/player/PlayerPage.tsx new file mode 100644 index 0000000..b1f2aa8 --- /dev/null +++ b/src/routes/player/PlayerPage.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import {getPlayableMedia} from "../../api/models/Title"; +import {usePlayabilityRating} from "../../util/mime/usePlayabilityRating"; +import {useCurrentTitle} from "../../util/CurrentTitleContext"; +import {Player} from "./Player"; +import {PlayerError} from "./PlayerError"; +import {PlayerLoading} from "./PlayerLoading"; + +export function PlayerPage() { + const title = useCurrentTitle(); + const playabilityRating = usePlayabilityRating(); + + if (title === null) { + return (<PlayerLoading/>); + } + + const media = getPlayableMedia(title.title, playabilityRating); + if (media === null) { + return (<PlayerError/>); + } + + return (<Player title={title} media={media}/>); +} diff --git a/src/routes/player/PreviewViewer.tsx b/src/routes/player/PreviewViewer.tsx new file mode 100644 index 0000000..fdca963 --- /dev/null +++ b/src/routes/player/PreviewViewer.tsx @@ -0,0 +1,23 @@ +import React, {useMemo} from "react"; +import {useTextTrackCues} from "../../util/media/useTextTrackCues"; +import {parseImageSprite} from "../../util/sprite/parseImageSprite"; +import {useImage} from "../../util/media/useImage"; +import {useImageSprite} from "../../util/sprite/useImageSprite"; + +interface Props { + previewTrack: HTMLTrackElement, + position: number, +} + +export function PreviewViewer({previewTrack, position}: Props) { + const cues = useTextTrackCues(previewTrack); + const activeCue = cues.find(it => it.startTime <= position && it.endTime >= position) + const activeUrl = activeCue ? new URL(activeCue.text, previewTrack.src).toString() : null; + const imageSprite = useMemo(() => parseImageSprite(activeUrl), [activeUrl]); + console.log("active", activeUrl, imageSprite); + const image = useImage(imageSprite?.src || null); + const sprite = useImageSprite(imageSprite, image); + return useMemo(() => ( + <img src={sprite || undefined}/> + ), [sprite]); +} diff --git a/src/routes/player/SeekBar.tsx b/src/routes/player/SeekBar.tsx new file mode 100644 index 0000000..792c0ff --- /dev/null +++ b/src/routes/player/SeekBar.tsx @@ -0,0 +1,87 @@ +import React, {useMemo, useRef, useState} from "react"; +import {createUseStyles} from "react-jss"; +import {PreviewViewer} from "./PreviewViewer"; + +interface Props { + previewTrack: HTMLTrackElement | null, + video: HTMLVideoElement | null, + duration: number, +} + +interface MousePosition { + absolute: number, + relative: number +} + +function getMousePosition(event: React.MouseEvent<HTMLDivElement>): MousePosition { + const position = event.clientX - event.currentTarget.offsetLeft; + const width = event.currentTarget.offsetWidth; + return { + absolute: position, + relative: position / width + }; +} + +export function SeekBar({video, previewTrack, duration}: Props) { + const classes = useStyles(); + + const seekHeadRef = useRef<HTMLDivElement | null>(null); + const [seekPosition, setSeekPosition] = useState<number | null>(null); + const isVisible = seekPosition !== null; + + const seekBar = useMemo(() => ( + <div + className={classes.seekBar} + onMouseLeave={() => setSeekPosition(null)} + onClick={(event) => { + const position = getMousePosition(event); + if (video) { + video.currentTime = position.relative * duration; + } + }} + onMouseMove={(event) => { + const position = getMousePosition(event); + if (seekHeadRef.current) { + seekHeadRef.current.style.transform = `translate3d(${position.absolute}px, 0, 0)` + } + window.requestAnimationFrame(() => { + setSeekPosition(position.relative * duration) + }) + }} + > + <div + className={classes.seekHead} + ref={seekHeadRef} + style={{ + opacity: isVisible ? 1 : 0 + }} + /> + </div> + ), [classes.seekBar, classes.seekHead, duration, isVisible]); + + return ( + <div> + {seekBar} + {seekPosition !== null && previewTrack && ( + <PreviewViewer previewTrack={previewTrack} position={seekPosition}/> + )} + </div> + ) +} + +const useStyles = createUseStyles({ + seekBar: { + position: "relative", + width: "40rem", + height: "5rem", + background: "#77c", + }, + seekHead: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + width: "0.1rem", + background: "#f00", + } +}); diff --git a/src/util/CurrentTitleContext.ts b/src/util/CurrentTitleContext.ts new file mode 100644 index 0000000..3576580 --- /dev/null +++ b/src/util/CurrentTitleContext.ts @@ -0,0 +1,7 @@ +import {createContext, useContext} from "react"; +import {TitleMeta} from "../api/models/dto/TitleMeta"; + +const CurrentTitleContext = createContext<TitleMeta | null>(null); +export const CurrentTitleProvider = CurrentTitleContext.Provider; +export const CurrentTitleConsumer = CurrentTitleContext.Consumer; +export const useCurrentTitle = () => useContext(CurrentTitleContext); diff --git a/src/locale/Locale.ts b/src/util/locale/Locale.ts similarity index 100% rename from src/locale/Locale.ts rename to src/util/locale/Locale.ts diff --git a/src/locale/LocalePriority.ts b/src/util/locale/LocalePriority.ts similarity index 100% rename from src/locale/LocalePriority.ts rename to src/util/locale/LocalePriority.ts diff --git a/src/util/locale/LocalizedContext.ts b/src/util/locale/LocalizedContext.ts new file mode 100644 index 0000000..6af244a --- /dev/null +++ b/src/util/locale/LocalizedContext.ts @@ -0,0 +1,10 @@ +import {createContext, useContext} from "react"; +import {Locale} from "./Locale"; + +const LocalizedContext = createContext<Locale>({ + language: "en", + region: "US", +}); +export const LocalizedProvider = LocalizedContext.Provider; +export const LocalizedConsumer = LocalizedContext.Consumer; +export const useLocale = () => useContext(LocalizedContext); diff --git a/src/locale/LocalizedData.ts b/src/util/locale/LocalizedData.ts similarity index 100% rename from src/locale/LocalizedData.ts rename to src/util/locale/LocalizedData.ts diff --git a/src/locale/RegionalData.ts b/src/util/locale/RegionalData.ts similarity index 100% rename from src/locale/RegionalData.ts rename to src/util/locale/RegionalData.ts diff --git a/src/locale/selectLocaleData.ts b/src/util/locale/selectLocaleData.ts similarity index 100% rename from src/locale/selectLocaleData.ts rename to src/util/locale/selectLocaleData.ts diff --git a/src/locale/selectRegionalData.ts b/src/util/locale/selectRegionalData.ts similarity index 100% rename from src/locale/selectRegionalData.ts rename to src/util/locale/selectRegionalData.ts diff --git a/src/util/media/MediaFragment.ts b/src/util/media/MediaFragment.ts new file mode 100644 index 0000000..a004d91 --- /dev/null +++ b/src/util/media/MediaFragment.ts @@ -0,0 +1,189 @@ +function warn(message: string) { + console.debug(`Media Fragments URI Parsing Warning: ${message}`); +} + +// the currently supported media fragments dimensions are: t, xywh, track, id +// allows for O(1) checks for existence of valid keys +const dimensions: { [key:string]: (value: string) => keyValuePairs | false } = { + t: function (value: string): keyValuePairs | false { + const npt = /^(?:(?:npt:)?(?:(?:(\d+):)?(\d\d):)?(\d+(?:\.\d*)?)?)$/; + + const components = value.split(','); + if (components.length > 2) { + return false; + } + const start = components[0] ? components[0] : ''; + const end = components[1] ? components[1] : ''; + if ((start === '' && end === '') || (start && !end && value.indexOf(',') !== -1)) { + return false; + } + const matchStart = npt.exec(start); + const matchEnd = npt.exec(end); + if (matchStart !== null && matchEnd !== null) { + const startNormalized = convertToSeconds(matchStart); + const endNormalized = convertToSeconds(matchEnd); + if (start && end) { + if (startNormalized < endNormalized) { + return { + value: value, + unit: 'npt', + start: start, + end: end, + startNormalized: startNormalized === false ? undefined : startNormalized, + endNormalized: endNormalized === false ? undefined : endNormalized + }; + } else { + warn('Please ensure that start < end.'); + return false; + } + } else { + if ((convertToSeconds(matchStart) !== false) || (convertToSeconds(matchEnd) !== false)) { + return { + value: value, + unit: 'npt', + start: start, + end: end, + startNormalized: startNormalized === false ? undefined : startNormalized, + endNormalized: endNormalized === false ? undefined : endNormalized + }; + } else { + warn('Please ensure that start or end are legal.'); + return false; + } + } + } + warn('Invalid time dimension.'); + return false; + }, xywh: function (value: string): keyValuePairs | false { + const xywh = /^(?:(pixel|percent):)?(\d+),(\d+),(\d+),(\d+)$/; + const match = xywh.exec(value); + if (!match) { + return false; + } + const type = match[1] || 'pixel'; + const x = parseInt(match[2], 10); + const y = parseInt(match[3], 10); + const w = parseInt(match[4], 10); + const h = parseInt(match[5], 10); + if (type === 'pixel') { + if (w > 0 && h > 0) { + return { + value: value, unit: 'pixel', x: x, y: y, w: w, h: h + }; + } else { + warn('Please ensure that w > 0 and h > 0'); + return false; + } + } else if (type === 'percent') { + if (checkPercentSelection(x, y, w, h)) { + return { + value: value, unit: 'percent', x: x, y: y, w: w, h: h + }; + } + warn('Invalid percent selection.'); + return false; + } else { + warn('Invalid spatial dimension.'); + return false; + } + }, track: function (value: string) { + return { + value: value, name: value + }; + }, id: function (value: string) { + return { + value: value, name: value + }; + }, chapter: function (value: string) { + return { + value: value, chapter: value + }; + } +}; + +/** + * checks for valid percent selections + */ +function checkPercentSelection(x: number, y: number, w: number, h: number): boolean { + if (!((0 <= x) && (x <= 100))) { + warn('Please ensure that 0 <= x <= 100.'); + return false; + } + if (!((0 <= y) && (y <= 100))) { + warn('Please ensure that 0 <= y <= 100.'); + return false; + } + if (!((0 <= w) && (w <= 100))) { + warn('Please ensure that 0 <= w <= 100.'); + return false; + } + if (!((0 <= h) && (h <= 100))) { + warn('Please ensure that 0 <= h <= 100.'); + return false; + } + if (x + w > 100) { + warn('Please ensure that x + w <= 100.'); + return false; + } + if (y + h > 100) { + warn('Please ensure that y + h <= 100.'); + return false; + } + return true; +} + +function convertToSeconds(match: RegExpMatchArray): number | false { + const hours = parseInt(match[0] || "0", 10); + const minutes = parseInt(match[1] || "0", 10); + const seconds = parseFloat(match[2]); + + if (hours > 23) { + warn('Please ensure that hours <= 23.'); + return false; + } + if (minutes > 59) { + warn('Please ensure that minutes <= 59.'); + return false; + } + if (hours !== 0 && minutes !== 0 && seconds >= 60) { + // this constraint must not be applied if you specify only seconds + warn('Please ensure that seconds < 60.'); + return false; + } + return hours * 3600 + minutes * 60 + seconds; +} + +function splitKeyValuePairs(fragment: string): { [key:string]: keyValuePairs | false } { + const params: { [key:string]: keyValuePairs | false } = {}; + fragment.split('&').forEach((hash: string) => { + const [key, val] = hash.split('=', 2); + if (Object.keys(dimensions).includes(key)) { + params[key] = dimensions[key](decodeURIComponent(val)); + } + }); + return params; +} + +interface keyValuePairs { + value?: string, + unit?: string, + + x?: number, + y?: number, + w?: number, + h?: number, + + start?: string, + startNormalized?: number, + end?: string, + endNormalized?: number, + + name?: string, + chapter?: string, +} + +function parse(optional_uri: string): { [key:string]: keyValuePairs | false } { + return splitKeyValuePairs(new URL(optional_uri || window.location.href)?.hash?.slice(1)); +} + +export default parse; diff --git a/src/util/media/useCanvas.ts b/src/util/media/useCanvas.ts new file mode 100644 index 0000000..8b5a305 --- /dev/null +++ b/src/util/media/useCanvas.ts @@ -0,0 +1,16 @@ +import {useLayoutEffect, useRef, useState} from "react"; + +export const useCanvas = (width: number | null, height: number | null): [HTMLCanvasElement, CanvasRenderingContext2D | null] => { + const canvas = useRef(document.createElement("canvas")); + const [context, setContext] = useState<CanvasRenderingContext2D | null>(null); + + useLayoutEffect(() => { + if (width !== null && height !== null) { + canvas.current.width = width; + canvas.current.height = height; + setContext(canvas.current.getContext("2d")); + } + }, [width, height, setContext]); + + return [canvas.current, context]; +} diff --git a/src/util/media/useDuration.ts b/src/util/media/useDuration.ts new file mode 100644 index 0000000..42cdb09 --- /dev/null +++ b/src/util/media/useDuration.ts @@ -0,0 +1,23 @@ +import {useEffect, useState} from "react"; + +export const useDuration = (video: HTMLVideoElement | null) => { + const [duration, setDuration] = useState<number>(0); + useEffect(() => { + if (video !== null) { + if (video.readyState >= 1) { + setDuration(video.duration); + } else { + const listener = () => { + window.requestAnimationFrame(() => { + setDuration(video.duration); + }) + }; + video.addEventListener("loadedmetadata", listener) + return () => { + video.removeEventListener("loadedmetadata", listener) + } + } + } + }, [video]); + return duration; +} diff --git a/src/util/media/useImage.ts b/src/util/media/useImage.ts new file mode 100644 index 0000000..7ec2d7e --- /dev/null +++ b/src/util/media/useImage.ts @@ -0,0 +1,18 @@ +import {useEffect, useRef, useState} from "react"; + +export const useImage = (url: string | null): HTMLImageElement | null => { + const image = useRef(new Image()) + image.current.setAttribute("crossorigin", "anonymous"); + const [imageData, setImageData] = useState<HTMLImageElement | null>(null); + useEffect(() => { + try { + image.current.src = url || ""; + image.current.decode().then(() => { + setImageData(image.current); + }) + } catch (_) { + } + }, [url]); + + return imageData; +} diff --git a/src/util/media/usePosition.ts b/src/util/media/usePosition.ts new file mode 100644 index 0000000..770ea75 --- /dev/null +++ b/src/util/media/usePosition.ts @@ -0,0 +1,19 @@ +import {useEffect, useState} from "react"; + +export const usePosition = (video: HTMLVideoElement | null) => { + const [position, setPosition] = useState<number>(0); + useEffect(() => { + if (video !== null) { + const listener = () => { + window.requestAnimationFrame(() => { + setPosition(video.currentTime); + }) + }; + video.addEventListener("progress", listener) + return () => { + video.removeEventListener("progress", listener) + } + } + }, [video]); + return position; +} diff --git a/src/util/media/useTextTrackCues.ts b/src/util/media/useTextTrackCues.ts new file mode 100644 index 0000000..136cab6 --- /dev/null +++ b/src/util/media/useTextTrackCues.ts @@ -0,0 +1,22 @@ +import {useEffect, useState} from "react"; + +export const useTextTrackCues = (track: HTMLTrackElement) => { + const [cues, setCues] = useState<TextTrackCue[]>([]); + useEffect(() => { + track.track.mode = "hidden"; + if (track.readyState >= 1) { + setCues(Array.from(track.track.cues || [])) + } else { + const listener = () => { + window.requestAnimationFrame(() => { + setCues(Array.from(track.track.cues || [])) + }) + }; + track.addEventListener("load", listener) + return () => { + track.removeEventListener("load", listener) + } + } + }, [track]); + return cues; +} diff --git a/src/mime/PlayabilityRating.ts b/src/util/mime/PlayabilityRating.ts similarity index 100% rename from src/mime/PlayabilityRating.ts rename to src/util/mime/PlayabilityRating.ts diff --git a/src/mime/usePlayabilityRating.ts b/src/util/mime/usePlayabilityRating.ts similarity index 64% rename from src/mime/usePlayabilityRating.ts rename to src/util/mime/usePlayabilityRating.ts index c3d6146..07868e9 100644 --- a/src/mime/usePlayabilityRating.ts +++ b/src/util/mime/usePlayabilityRating.ts @@ -1,5 +1,6 @@ import {useRef} from "react"; -import {Media} from "../api/models/Media"; +import {Media} from "../../api/models/Media"; +import {sortNumericallyDesc} from "../sort/sortNumerically"; import {PlayabilityRating} from "./PlayabilityRating"; import {videoMimeString} from "./videoMimeString"; @@ -16,3 +17,10 @@ export const usePlayabilityRating = () => { } } } + +export const selectPlayabileMedia = ( + playabilityRating: (media: Media) => PlayabilityRating, + media: Media[] +): Media | null => + media.sort(sortNumericallyDesc(it => playabilityRating(it)))[0] + || null diff --git a/src/mime/videoMimeString.ts b/src/util/mime/videoMimeString.ts similarity index 70% rename from src/mime/videoMimeString.ts rename to src/util/mime/videoMimeString.ts index 1c8d61d..0ba692a 100644 --- a/src/mime/videoMimeString.ts +++ b/src/util/mime/videoMimeString.ts @@ -1,4 +1,4 @@ -import {Media} from "../api/models/Media"; +import {Media} from "../../api/models/Media"; export const videoMimeString = (media: Media) => `${media.mime}; codecs="${media.codecs.join(", ")}"`; diff --git a/src/request/RequestClient.ts b/src/util/request/RequestClient.ts similarity index 100% rename from src/request/RequestClient.ts rename to src/util/request/RequestClient.ts diff --git a/src/request/RequestError.ts b/src/util/request/RequestError.ts similarity index 100% rename from src/request/RequestError.ts rename to src/util/request/RequestError.ts diff --git a/src/request/RequestErrorKind.ts b/src/util/request/RequestErrorKind.ts similarity index 100% rename from src/request/RequestErrorKind.ts rename to src/util/request/RequestErrorKind.ts diff --git a/src/util/sortLexically.ts b/src/util/sort/sortLexically.ts similarity index 100% rename from src/util/sortLexically.ts rename to src/util/sort/sortLexically.ts diff --git a/src/util/sortNumerically.ts b/src/util/sort/sortNumerically.ts similarity index 100% rename from src/util/sortNumerically.ts rename to src/util/sort/sortNumerically.ts diff --git a/src/util/sprite/ImageSprite.tsx b/src/util/sprite/ImageSprite.tsx new file mode 100644 index 0000000..afe0d25 --- /dev/null +++ b/src/util/sprite/ImageSprite.tsx @@ -0,0 +1,9 @@ +export interface ImageSprite { + src: string, + fragment: { + x: number, + y: number, + w: number, + h: number, + } +} diff --git a/src/util/sprite/parseImageSprite.tsx b/src/util/sprite/parseImageSprite.tsx new file mode 100644 index 0000000..2d495fe --- /dev/null +++ b/src/util/sprite/parseImageSprite.tsx @@ -0,0 +1,27 @@ +import parse from "../media/MediaFragment"; +import {ImageSprite} from "./ImageSprite"; + +export function parseImageSprite(src: string | null): ImageSprite | null { + if (src === null) { + return null; + } + const fragment = parse(src)["xywh"] || undefined; + if (fragment?.x !== undefined + && fragment?.y !== undefined + && fragment?.w !== undefined + && fragment?.h !== undefined) { + const url = new URL(src); + url.hash = ""; + return { + src: url.toString(), + fragment: { + x: fragment.x, + y: fragment.y, + w: fragment.w, + h: fragment.h, + } + } + } else { + return null; + } +} diff --git a/src/util/sprite/useImageSprite.ts b/src/util/sprite/useImageSprite.ts new file mode 100644 index 0000000..528e3f7 --- /dev/null +++ b/src/util/sprite/useImageSprite.ts @@ -0,0 +1,23 @@ +import {useEffect, useState} from "react"; +import {ImageSprite} from "./ImageSprite"; +import {useCanvas} from "../media/useCanvas"; + +export const useImageSprite = (imageSprite: ImageSprite | null, image: HTMLImageElement | null): string | null => { + const [canvas, context] = useCanvas( + imageSprite?.fragment?.w || null, + imageSprite?.fragment?.h || null + ); + + const [data, setData] = useState<string | null>(null); + useEffect(() => { + if (canvas && context && image && imageSprite?.fragment) { + const {x, y, w, h} = imageSprite.fragment; + context.drawImage(image, x, y, w, h, 0, 0, w, h); + setData(canvas.toDataURL("image/png")); + } else if (imageSprite?.fragment === null) { + setData(imageSprite.src); + } + }, [canvas, context, image, imageSprite]); + + return data; +} -- GitLab