From 6227426b01271694bbb85eab6fa381d5cfd19fa0 Mon Sep 17 00:00:00 2001 From: Janne Koschinski <janne@kuschku.de> Date: Thu, 26 Nov 2020 22:42:39 +0100 Subject: [PATCH] Implement basic DASH handling --- package-lock.json | 78 +++++++++++ package.json | 3 + src/App.tsx | 8 +- src/api/ApiClient.ts | 22 +-- src/api/ApiClientContext.ts | 4 +- src/api/models/Content.ts | 48 +++++++ ...leDescription.ts => ContentDescription.ts} | 2 +- src/api/models/{TitleId.ts => ContentId.ts} | 2 +- .../models/{TitleKind.ts => ContentKind.ts} | 2 +- .../models/{TitleName.ts => ContentName.ts} | 2 +- src/api/models/{Episode.ts => Instalment.ts} | 6 +- src/api/models/Title.ts | 48 ------- src/api/models/dto/ContentMeta.ts | 7 + src/api/models/dto/GenreWithContent.ts | 7 + src/api/models/dto/GenreWithTitles.ts | 7 - src/api/models/dto/TitleMeta.ts | 7 - src/routes/ContentRoute.tsx | 21 +++ src/routes/TitleRoute.tsx | 21 --- src/routes/main/MainPage.tsx | 30 ++-- src/routes/player/Player.tsx | 62 +++++---- src/routes/player/PlayerPage.tsx | 12 +- src/routes/player/SeekBar.tsx | 5 +- src/routes/player/video/DashVideoElement.tsx | 129 ++++++++++++++++++ src/routes/player/video/RawVideoElement.tsx | 129 ++++++++++++++++++ src/routes/player/video/VideoApi.ts | 35 +++++ src/routes/player/video/VideoElement.tsx | 23 ++++ src/util/CurrentContentContext.ts | 7 + src/util/CurrentTitleContext.ts | 7 - src/util/media/useAudioTracks.ts | 25 ++++ src/util/media/useDebugInfo.ts | 22 +++ src/util/media/useDuration.ts | 13 +- src/util/media/usePosition.ts | 9 +- 32 files changed, 632 insertions(+), 171 deletions(-) create mode 100644 src/api/models/Content.ts rename src/api/models/{TitleDescription.ts => ContentDescription.ts} (72%) rename src/api/models/{TitleId.ts => ContentId.ts} (76%) rename src/api/models/{TitleKind.ts => ContentKind.ts} (71%) rename src/api/models/{TitleName.ts => ContentName.ts} (70%) rename src/api/models/{Episode.ts => Instalment.ts} (50%) delete mode 100644 src/api/models/Title.ts create mode 100644 src/api/models/dto/ContentMeta.ts create mode 100644 src/api/models/dto/GenreWithContent.ts delete mode 100644 src/api/models/dto/GenreWithTitles.ts delete mode 100644 src/api/models/dto/TitleMeta.ts create mode 100644 src/routes/ContentRoute.tsx delete mode 100644 src/routes/TitleRoute.tsx create mode 100644 src/routes/player/video/DashVideoElement.tsx create mode 100644 src/routes/player/video/RawVideoElement.tsx create mode 100644 src/routes/player/video/VideoApi.ts create mode 100644 src/routes/player/video/VideoElement.tsx create mode 100644 src/util/CurrentContentContext.ts delete mode 100644 src/util/CurrentTitleContext.ts create mode 100644 src/util/media/useAudioTracks.ts create mode 100644 src/util/media/useDebugInfo.ts diff --git a/package-lock.json b/package-lock.json index 24eba99..833b9fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3673,6 +3673,11 @@ "q": "^1.1.2" } }, + "codem-isoboxer": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.6.tgz", + "integrity": "sha512-LuO8/7LW6XuR5ERn1yavXAfodGRhuY2yP60JTZIw5yNYMCE5lUVbk3NFUCJxjnphQH+Xemp5hOGb1LgUXm00Xw==" + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -4341,6 +4346,27 @@ "assert-plus": "^1.0.0" } }, + "dashjs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-3.1.3.tgz", + "integrity": "sha512-eymNYAr1KTMNTuq9k1W9UPwkFvWvpR+ykKTXQnPnD/W00DVFqdl4bZ1B4MUdFHFE3H38Bij1/cigRnDydXJvsQ==", + "requires": { + "codem-isoboxer": "0.3.6", + "fast-deep-equal": "2.0.1", + "html-entities": "^1.2.1", + "imsc": "^1.0.2", + "localforage": "^1.7.1", + "request": "^2.87.0", + "request-promise": "^4.2.2" + }, + "dependencies": { + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + } + } + }, "data-urls": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz", @@ -6684,6 +6710,11 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==" }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, "immer": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz", @@ -6723,6 +6754,21 @@ "resolve-cwd": "^2.0.0" } }, + "imsc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.1.tgz", + "integrity": "sha512-kfWjrmg/vdcqM65FPxpq46RyxKTpfMikDk0PhXOeAlZ6o1OkWBZsll4TlmSj261WcuNT252VBB0aGqSQ+vBZ/A==", + "requires": { + "sax": "1.2.1" + }, + "dependencies": { + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + } + } + }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8112,6 +8158,14 @@ "type-check": "~0.3.2" } }, + "lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", + "requires": { + "immediate": "~3.0.5" + } + }, "lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -8199,6 +8253,14 @@ } } }, + "localforage": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.9.0.tgz", + "integrity": "sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g==", + "requires": { + "lie": "3.1.1" + } + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -11003,6 +11065,11 @@ "tiny-warning": "^1.0.2" } }, + "react-merge-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-1.1.0.tgz", + "integrity": "sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==" + }, "react-router": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", @@ -11382,6 +11449,17 @@ "uuid": "^3.3.2" } }, + "request-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", + "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", + "requires": { + "bluebird": "^3.5.0", + "request-promise-core": "1.1.4", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + } + }, "request-promise-core": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", diff --git a/package.json b/package.json index c90d4d7..6e2091d 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "@types/react-dom": "^16.9.8", "@types/react-router": "^5.1.8", "@types/react-router-dom": "^5.1.5", + "dashjs": "^3.1.3", "immer": "^7.0.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-jss": "^10.4.0", + "react-merge-refs": "^1.1.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-scripts": "3.4.3", @@ -29,6 +31,7 @@ "test": "react-scripts test", "eject": "react-scripts eject" }, + "proxy": "http://localhost:8000", "eslintConfig": { "extends": "react-app" }, diff --git a/src/App.tsx b/src/App.tsx index b61c403..46aa883 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,15 +2,15 @@ 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"; +import {ContentRoute} from "./routes/ContentRoute"; export function App() { return ( <BrowserRouter> - <Route path="/player/:titleId" exact> - <TitleRoute> + <Route path="/player/:contentId" exact> + <ContentRoute> <PlayerPage/> - </TitleRoute> + </ContentRoute> </Route> <Route path="/" exact> <MainPage/> diff --git a/src/api/ApiClient.ts b/src/api/ApiClient.ts index 91d3e36..972326f 100644 --- a/src/api/ApiClient.ts +++ b/src/api/ApiClient.ts @@ -1,9 +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 {GenreWithContent} from "./models/dto/GenreWithContent"; +import {ContentMeta} from "./models/dto/ContentMeta"; +import {Instalment} from "./models/Instalment"; import {Genre} from "./models/Genre"; -import {Title} from "./models/Title"; +import {Content} from "./models/Content"; export class ApiClient extends RequestClient { public async listGenres(): Promise<Genre[]> { @@ -12,26 +12,26 @@ export class ApiClient extends RequestClient { }); } - public async getGenre(genreId: string): Promise<GenreWithTitles> { + public async getGenre(genreId: string): Promise<GenreWithContent> { return await this.request(`api/v1/genres/${genreId}`, { method: "GET" }); } - public async listTitles(): Promise<Title[]> { - return await this.request(`api/v1/titles`, { + public async listContent(): Promise<Content[]> { + return await this.request(`api/v1/content`, { method: "GET" }); } - public async getTitle(titleId: string): Promise<TitleMeta> { - return await this.request(`api/v1/titles/${titleId}`, { + public async getContent(contentId: string): Promise<ContentMeta> { + return await this.request(`api/v1/content/${contentId}`, { method: "GET" }); } - public async listEpisodes(titleId: string): Promise<Episode> { - return await this.request(`api/v1/titles/${titleId}/episodes`, { + public async listEpisodes(contentId: string): Promise<Instalment> { + return await this.request(`api/v1/content/${contentId}/episodes`, { method: "GET" }); } diff --git a/src/api/ApiClientContext.ts b/src/api/ApiClientContext.ts index 22e326d..f8f2618 100644 --- a/src/api/ApiClientContext.ts +++ b/src/api/ApiClientContext.ts @@ -8,9 +8,7 @@ interface ApiClientContext { const ApiClientContext = createContext<ApiClientContext>({ apiClient: new ApiClient( localStorage.getItem("API_ENDPOINT") || - process.env.NODE_ENV === "development" - ? "http://localhost:8000/" - : new URL("/", window.location.href).toString() + new URL("/", window.location.href).toString() ) }); diff --git a/src/api/models/Content.ts b/src/api/models/Content.ts new file mode 100644 index 0000000..f31b39c --- /dev/null +++ b/src/api/models/Content.ts @@ -0,0 +1,48 @@ +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"; +import {Media} from "./Media"; +import {Rating} from "./Rating"; +import {Subtitle} from "./Subtitle"; +import {ContentDescription} from "./ContentDescription"; +import {ContentId} from "./ContentId"; +import {ContentKind} from "./ContentKind"; +import {ContentName} from "./ContentName"; + +export interface Content { + ids: ContentId, + kind: ContentKind, + originalLanguage: string | null, + runtime: number | null, + yearStart: number | null, + yearEnd: number | null, + titles: ContentName[], + descriptions: ContentDescription[], + cast: Cast[], + genres: Genre[], + ratings: Rating[], + images: Image[], + media: Media[], + subtitles: Subtitle[], + preview: string | null, + createdAt: string, + updatedAt: string +} + +export const getLocalizedName = (content: Content, locale: Locale): ContentName | null => + selectLocaleVersion(locale, LocalePriority.REGION, content.titles) + +export const getLocalizedDescription = (content: Content, locale: Locale): ContentDescription | null => + selectLocaleVersion(locale, LocalePriority.LOCALE, content.descriptions) + +export const getLocalizedRating = (content: Content, locale: Locale): Rating | null => + selectRegionalVersion(locale, content.ratings) + +export const getPlayableMedia = (content: Content, playabilityRating: (media: Media) => PlayabilityRating): Media | null => + selectPlayabileMedia(playabilityRating, content.media) diff --git a/src/api/models/TitleDescription.ts b/src/api/models/ContentDescription.ts similarity index 72% rename from src/api/models/TitleDescription.ts rename to src/api/models/ContentDescription.ts index 3a8a94f..1c8b0b4 100644 --- a/src/api/models/TitleDescription.ts +++ b/src/api/models/ContentDescription.ts @@ -1,6 +1,6 @@ import {LocalizedData} from "../../util/locale/LocalizedData"; -export interface TitleDescription extends LocalizedData { +export interface ContentDescription extends LocalizedData { overview: string, tagline: string | null, languages: string[], diff --git a/src/api/models/TitleId.ts b/src/api/models/ContentId.ts similarity index 76% rename from src/api/models/TitleId.ts rename to src/api/models/ContentId.ts index 249afde..e0992d8 100644 --- a/src/api/models/TitleId.ts +++ b/src/api/models/ContentId.ts @@ -1,4 +1,4 @@ -export interface TitleId { +export interface ContentId { uuid: string, imdb: string | null, tmdb: number | null, diff --git a/src/api/models/TitleKind.ts b/src/api/models/ContentKind.ts similarity index 71% rename from src/api/models/TitleKind.ts rename to src/api/models/ContentKind.ts index be3f792..79bc107 100644 --- a/src/api/models/TitleKind.ts +++ b/src/api/models/ContentKind.ts @@ -1,4 +1,4 @@ -export enum TitleKind { +export enum ContentKind { Movie = "movie", Show = "show", Episode = "episode" diff --git a/src/api/models/TitleName.ts b/src/api/models/ContentName.ts similarity index 70% rename from src/api/models/TitleName.ts rename to src/api/models/ContentName.ts index 98f66b5..a148037 100644 --- a/src/api/models/TitleName.ts +++ b/src/api/models/ContentName.ts @@ -1,6 +1,6 @@ import {LocalizedData} from "../../util/locale/LocalizedData"; -export interface TitleName extends LocalizedData { +export interface ContentName extends LocalizedData { name: string, languages: string[], kind: string diff --git a/src/api/models/Episode.ts b/src/api/models/Instalment.ts similarity index 50% rename from src/api/models/Episode.ts rename to src/api/models/Instalment.ts index 2463d61..b322709 100644 --- a/src/api/models/Episode.ts +++ b/src/api/models/Instalment.ts @@ -1,8 +1,8 @@ -import {Title} from "./Title"; +import {Content} from "./Content"; -export interface Episode { +export interface Instalment { season: string | null, episode: string | null, airDate: string | null, - title: Title + content: Content } diff --git a/src/api/models/Title.ts b/src/api/models/Title.ts deleted file mode 100644 index 5e0caec..0000000 --- a/src/api/models/Title.ts +++ /dev/null @@ -1,48 +0,0 @@ -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"; -import {Media} from "./Media"; -import {Rating} from "./Rating"; -import {Subtitle} from "./Subtitle"; -import {TitleDescription} from "./TitleDescription"; -import {TitleId} from "./TitleId"; -import {TitleKind} from "./TitleKind"; -import {TitleName} from "./TitleName"; - -export interface Title { - ids: TitleId, - kind: TitleKind, - originalLanguage: string | null, - runtime: number | null, - yearStart: number | null, - yearEnd: number | null, - titles: TitleName[], - descriptions: TitleDescription[], - cast: Cast[], - genres: Genre[], - ratings: Rating[], - images: Image[], - media: Media[], - subtitles: Subtitle[], - preview: string | null, - createdAt: string, - updatedAt: string -} - -export const getLocalizedName = (title: Title, locale: Locale): TitleName | null => - selectLocaleVersion(locale, LocalePriority.REGION, title.titles) - -export const getLocalizedDescription = (title: Title, locale: Locale): TitleDescription | null => - selectLocaleVersion(locale, LocalePriority.LOCALE, title.descriptions) - -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/dto/ContentMeta.ts b/src/api/models/dto/ContentMeta.ts new file mode 100644 index 0000000..cecb1f2 --- /dev/null +++ b/src/api/models/dto/ContentMeta.ts @@ -0,0 +1,7 @@ +import {Instalment} from "../Instalment"; +import {Content} from "../Content"; + +export interface ContentMeta { + content: Content, + instalment: Instalment | null, +} diff --git a/src/api/models/dto/GenreWithContent.ts b/src/api/models/dto/GenreWithContent.ts new file mode 100644 index 0000000..42adf74 --- /dev/null +++ b/src/api/models/dto/GenreWithContent.ts @@ -0,0 +1,7 @@ +import {Genre} from "../Genre"; +import {Content} from "../Content"; + +export interface GenreWithContent { + genre: Genre, + content: Content[], +} diff --git a/src/api/models/dto/GenreWithTitles.ts b/src/api/models/dto/GenreWithTitles.ts deleted file mode 100644 index 2d93e32..0000000 --- a/src/api/models/dto/GenreWithTitles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {Genre} from "../Genre"; -import {Title} from "../Title"; - -export interface GenreWithTitles { - genre: Genre, - titles: Title[], -} diff --git a/src/api/models/dto/TitleMeta.ts b/src/api/models/dto/TitleMeta.ts deleted file mode 100644 index a6883fb..0000000 --- a/src/api/models/dto/TitleMeta.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {Episode} from "../Episode"; -import {Title} from "../Title"; - -export interface TitleMeta { - title: Title, - show: Episode | null, -} diff --git a/src/routes/ContentRoute.tsx b/src/routes/ContentRoute.tsx new file mode 100644 index 0000000..be5f46f --- /dev/null +++ b/src/routes/ContentRoute.tsx @@ -0,0 +1,21 @@ +import React, {PropsWithChildren, useEffect, useState} from "react"; +import {useParams} from "react-router"; +import {useApiClient} from "../api/ApiClientContext"; +import {ContentMeta} from "../api/models/dto/ContentMeta"; +import {CurrentContentProvider} from "../util/CurrentContentContext"; + +export function ContentRoute(props: PropsWithChildren<{}>) { + const {children} = props; + const {contentId} = useParams<{ contentId: string }>(); + const {apiClient} = useApiClient(); + const [meta, setMeta] = useState<ContentMeta | null>(null); + useEffect(() => { + apiClient.getContent(contentId).then(setMeta); + }, [apiClient, contentId, setMeta]); + + return ( + <CurrentContentProvider value={meta}> + {children} + </CurrentContentProvider> + ) +} diff --git a/src/routes/TitleRoute.tsx b/src/routes/TitleRoute.tsx deleted file mode 100644 index ccbaf91..0000000 --- a/src/routes/TitleRoute.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 index 5b3d18a..3501ace 100644 --- a/src/routes/main/MainPage.tsx +++ b/src/routes/main/MainPage.tsx @@ -2,8 +2,8 @@ import React, {useEffect, useState} from "react"; import {createUseStyles} from "react-jss"; import {Link} from "react-router-dom"; import {useApiClient} from "../../api/ApiClientContext"; -import {Episode} from "../../api/models/Episode"; -import {getLocalizedDescription, getLocalizedName, getLocalizedRating, Title} from "../../api/models/Title"; +import {Instalment} from "../../api/models/Instalment"; +import {getLocalizedDescription, getLocalizedName, getLocalizedRating, Content} from "../../api/models/Content"; import {Locale} from "../../util/locale/Locale"; import {sortLexicallyAsc} from "../../util/sort/sortLexically"; @@ -13,31 +13,31 @@ interface Props { export function MainPage(props: Props) { const {apiClient} = useApiClient(); - const [data, setData] = useState<Title[]>(); + const [data, setData] = useState<Content[]>(); useEffect(() => { - apiClient.listTitles().then(setData); + apiClient.listContent().then(setData); }, [apiClient]); const locale: Locale = { language: "en", region: "DE", }; - const [episodes, setEpisodes] = useState<{ [key: string]: Episode[] }>({}); - const titlesToLoad = data?.filter(it => it.kind === "show" && episodes[it.ids.uuid] === undefined); + const [episodes, setEpisodes] = useState<{ [key: string]: Instalment[] }>({}); + const contentToLoad = data?.filter(it => it.kind === "show" && episodes[it.ids.uuid] === undefined); useEffect(() => { - if (titlesToLoad?.length !== undefined && titlesToLoad.length > 0) { - const titlesBeingLoaded = Object.fromEntries(titlesToLoad?.map(it => [it.ids.uuid, []]) || []); - setEpisodes({...titlesBeingLoaded, ...episodes}); - Promise.all(titlesToLoad?.map(it => apiClient.listEpisodes(it.ids.uuid).then(eps => [it.ids.uuid, eps])) || []) + if (contentToLoad?.length !== undefined && contentToLoad.length > 0) { + const contentBeingLoaded = Object.fromEntries(contentToLoad?.map(it => [it.ids.uuid, []]) || []); + setEpisodes({...contentBeingLoaded, ...episodes}); + Promise.all(contentToLoad?.map(it => apiClient.listEpisodes(it.ids.uuid).then(eps => [it.ids.uuid, eps])) || []) .then(it => setEpisodes({...Object.fromEntries(it), ...episodes})); } - }, [apiClient, episodes, titlesToLoad]); + }, [apiClient, episodes, contentToLoad]); const classes = useStyles(); return ( <div> {data?.sort(sortLexicallyAsc(item => getLocalizedName(item, locale)?.name || "")) - .map((item: Title) => { + .map((item: Content) => { const title = getLocalizedName(item, locale); const description = getLocalizedDescription(item, locale); const rating = getLocalizedRating(item, locale); @@ -58,8 +58,8 @@ export function MainPage(props: Props) { {item.kind === "show" && ( <ul> {episodes[item.ids.uuid]?.map(episode => { - const episodeTitle = getLocalizedName(episode.title, locale); - const episodeDescription = getLocalizedDescription(episode.title, locale); + const episodeTitle = getLocalizedName(episode.content, locale); + const episodeDescription = getLocalizedDescription(episode.content, locale); return ( <li> <p> @@ -69,7 +69,7 @@ export function MainPage(props: Props) { <p><strong>{episodeDescription?.tagline}</strong></p> <p>{episodeDescription?.overview}</p> <p> - <Link to={"/player/" + episode.title.ids.uuid}>Play</Link> + <Link to={"/player/" + episode.content.ids.uuid}>Play</Link> </p> </li> ) diff --git a/src/routes/player/Player.tsx b/src/routes/player/Player.tsx index 59055a6..132716c 100644 --- a/src/routes/player/Player.tsx +++ b/src/routes/player/Player.tsx @@ -1,52 +1,68 @@ -import React, {useMemo, useState} from "react"; +import React, {useState} from "react"; import {createUseStyles} from "react-jss"; import {Link} from "react-router-dom"; -import {TitleMeta} from "../../api/models/dto/TitleMeta"; +import {ContentMeta} from "../../api/models/dto/ContentMeta"; import {Media} from "../../api/models/Media"; -import {getLocalizedDescription, getLocalizedName, getLocalizedRating} from "../../api/models/Title"; +import {getLocalizedDescription, getLocalizedName, getLocalizedRating} from "../../api/models/Content"; import {formatDuration} from "../../util/formatDuration"; import {useLocale} from "../../util/locale/LocalizedContext"; import {useDuration} from "../../util/media/useDuration"; import {usePosition} from "../../util/media/usePosition"; import {SeekBar} from "./SeekBar"; +import {VideoElement} from "./video/VideoElement"; +import {VideoApi} from "./video/VideoApi"; +import {useAudioTracks} from "../../util/media/useAudioTracks"; +import {useDebugInfo} from "../../util/media/useDebugInfo"; interface Props { - title: TitleMeta, + meta: ContentMeta, media: Media, } -export function Player(props: Props) { - const {media} = props; - const {title, show} = props.title; - +export function Player( + {meta, media}: Props +) { + const {content, instalment} = meta; const classes = useStyles(); const locale = useLocale(); - const name = getLocalizedName(title, locale); - const description = getLocalizedDescription(title, locale); - const rating = getLocalizedRating(title, locale); + const name = getLocalizedName(content, locale); + const description = getLocalizedDescription(content, locale); + const rating = getLocalizedRating(content, locale); const [previewTrackElement, setPreviewTrackElement] = useState<HTMLTrackElement | null>(null); - const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null); + const [videoElement, setVideoElement] = useState<VideoApi | null>(null); + useDebugInfo("content", content); + useDebugInfo("player", videoElement); - 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]); + const duration = useDuration(videoElement); + const audioTracks = useAudioTracks(videoElement); return ( <div> <Link to="/">Back</Link> <p>{name?.name}</p> - <p>{show && getLocalizedName(show.title, locale)?.name}</p> - {video} + <p>{instalment?.content && getLocalizedName(instalment?.content, locale)?.name}</p> + <VideoElement + media={media} + autoPlay={true} + ref={setVideoElement} + previewSrc={content.preview || undefined} + previewTrackRef={setPreviewTrackElement} + /> <p style={{fontVariant: "tabular-nums"}}>{formatDuration(position)} / {formatDuration(duration)}</p> + <button onClick={videoElement?.play}>Play</button> + <button onClick={videoElement?.pause}>Pause</button> + <ul> + {audioTracks.map(track => ( + <li key={track.index}> + <strong>{track.lang}</strong> + {track.labels} + <button onClick={() => videoElement?.setAudioTrack(track)}>Choose</button> + </li> + ))} + </ul> <SeekBar video={videoElement} previewTrack={previewTrackElement} diff --git a/src/routes/player/PlayerPage.tsx b/src/routes/player/PlayerPage.tsx index b1f2aa8..eb96af0 100644 --- a/src/routes/player/PlayerPage.tsx +++ b/src/routes/player/PlayerPage.tsx @@ -1,23 +1,23 @@ import React from "react"; -import {getPlayableMedia} from "../../api/models/Title"; +import {getPlayableMedia} from "../../api/models/Content"; import {usePlayabilityRating} from "../../util/mime/usePlayabilityRating"; -import {useCurrentTitle} from "../../util/CurrentTitleContext"; +import {useCurrentContent} from "../../util/CurrentContentContext"; import {Player} from "./Player"; import {PlayerError} from "./PlayerError"; import {PlayerLoading} from "./PlayerLoading"; export function PlayerPage() { - const title = useCurrentTitle(); + const content = useCurrentContent(); const playabilityRating = usePlayabilityRating(); - if (title === null) { + if (content === null) { return (<PlayerLoading/>); } - const media = getPlayableMedia(title.title, playabilityRating); + const media = getPlayableMedia(content.content, playabilityRating); if (media === null) { return (<PlayerError/>); } - return (<Player title={title} media={media}/>); + return (<Player meta={content} media={media}/>); } diff --git a/src/routes/player/SeekBar.tsx b/src/routes/player/SeekBar.tsx index b138c8e..90bcbda 100644 --- a/src/routes/player/SeekBar.tsx +++ b/src/routes/player/SeekBar.tsx @@ -5,10 +5,11 @@ import {MousePosition} from "../../util/mouse/MousePosition"; import {useOffsetAbsolute} from "../../util/offset/useOffsetAbsolute"; import {useOffsetRelative} from "../../util/offset/useOffsetRelative"; import {PreviewBar} from "./PreviewBar"; +import {VideoApi} from "./video/VideoApi"; interface Props { previewTrack: HTMLTrackElement | null, - video: HTMLVideoElement | null, + video: VideoApi | null, duration: number, position: number, } @@ -34,7 +35,7 @@ export function SeekBar({video, previewTrack, duration, position}: Props) { const position = getMousePosition(event); setMousePosition(position); if (video && position) { - video.currentTime = position.relative * duration; + video.setCurrentTime(position.relative * duration); } }, [duration, video]); diff --git a/src/routes/player/video/DashVideoElement.tsx b/src/routes/player/video/DashVideoElement.tsx new file mode 100644 index 0000000..43cd84b --- /dev/null +++ b/src/routes/player/video/DashVideoElement.tsx @@ -0,0 +1,129 @@ +import React, {useEffect, useImperativeHandle, useMemo, useState} from "react"; +import {createUseStyles} from "react-jss"; +import {Media} from "../../../api/models/Media"; +import dashjs, {MediaInfo} from "dashjs"; +import {VideoApi} from "./VideoApi"; + +interface Props { + media: Media, + autoPlay: boolean, + previewSrc?: string, + previewTrackRef: React.Ref<HTMLTrackElement> +} + +interface VideoQuality { + +} + +export const DashVideoElement = React.forwardRef<VideoApi, Props>(function ( + {media, autoPlay, previewSrc, previewTrackRef}, + ref +) { + const classes = useStyles(); + + const player = useMemo(() => dashjs.MediaPlayer().create(), []); + const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null); + + useImperativeHandle(ref, () => ({ + METADATA_EVENT: dashjs.MediaPlayer.events.PLAYBACK_METADATA_LOADED, + TIMECHANGE_EVENT: dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED, + play: () => { + player.play(); + }, + pause: () => { + player.pause(); + }, + isPaused: () => { + return player.isPaused(); + }, + setPlaybackRate: (value: number) => { + player.setPlaybackRate(value); + }, + getPlaybackRate: () => { + return player.getPlaybackRate(); + }, + setCurrentTime(value: number) { + player.seek(value); + }, + getCurrentTime(): number { + return player.time(); + }, + getDuration(): number { + return player.duration(); + }, + canPlay(): boolean { + return !Number.isNaN(player.duration()); + }, + setAudioTrack(track: MediaInfo) { + player.setQualityFor("audio", 0); + player.setCurrentTrack(track); + }, + addEventListener(event: string, listener: () => void) { + player.on(event, listener); + }, + removeEventListener(event: string, listener: () => void) { + player.off(event, listener); + }, + getAudioTracks(): MediaInfo[] { + return player.getTracksFor("audio").map((info: MediaInfo) => { + return info; + }) + }, + debug(): any { + return player; + } + }), [player]); + + useEffect(() => { + player.initialize(); + player.setTrackSwitchModeFor("audio", "alwaysReplace"); + return () => { + return player.reset(); + } + }, [player]); + + useEffect(() => { + if (videoElement) { + player.attachView(videoElement) + } + return () => { + // @ts-ignore + player.attachView(null); + } + }, [player, videoElement]); + + useEffect(() => { + player.attachSource(media.src); + return () => { + // @ts-ignore + player.attachSource(null); + } + }, [player, media]); + + useEffect(() => { + player.setAutoPlay(autoPlay); + }, [player, autoPlay]); + + return ( + <video + ref={setVideoElement} + className={classes.player} + crossOrigin="anonymous" + controls + > + {previewSrc && ( + <track ref={previewTrackRef} kind="metadata" label="previews" src={previewSrc}/> + )} + </video> + ); +}); + +const useStyles = createUseStyles({ + player: { + maxWidth: "40rem", + margin: { + left: "auto", + right: "auto", + } + } +}); diff --git a/src/routes/player/video/RawVideoElement.tsx b/src/routes/player/video/RawVideoElement.tsx new file mode 100644 index 0000000..13a5448 --- /dev/null +++ b/src/routes/player/video/RawVideoElement.tsx @@ -0,0 +1,129 @@ +import React, {useImperativeHandle, useState} from "react"; +import {createUseStyles} from "react-jss"; +import {Media} from "../../../api/models/Media"; +import {VideoApi} from "./VideoApi"; +import {MediaInfo} from "dashjs"; + +interface Props { + media: Media, + autoPlay: boolean, + previewSrc?: string, + previewTrackRef: React.Ref<HTMLTrackElement> +} + +export const RawVideoElement = React.forwardRef<VideoApi, Props>(function ( + {media, autoPlay, previewSrc, previewTrackRef}, + ref +) { + const classes = useStyles(); + const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null); + + useImperativeHandle(ref, () => ({ + METADATA_EVENT: "loadedmetadata", + TIMECHANGE_EVENT: "timeupdate", + play() { + if (!videoElement) { + return; + } + + videoElement.play(); + }, + pause() { + if (!videoElement) { + return; + } + + videoElement.pause(); + }, + isPaused(): boolean { + if (!videoElement) { + return true; + } + + return videoElement.paused; + }, + setPlaybackRate(value: number) { + if (!videoElement) { + return; + } + + videoElement.playbackRate = value; + }, + getPlaybackRate(): number { + if (!videoElement) { + return 1; + } + + return videoElement.playbackRate; + }, + setCurrentTime(value: number) { + if (!videoElement) { + return; + } + + videoElement.currentTime = value; + }, + getCurrentTime(): number { + if (!videoElement) { + return 0; + } + + return videoElement.currentTime; + }, + getDuration(): number { + if (!videoElement) { + return 0; + } + + return videoElement.duration; + }, + canPlay(): boolean { + if (!videoElement) { + return false; + } + + return videoElement.readyState >= 1; + }, + getAudioTracks(): MediaInfo[] { + return []; + }, + setAudioTrack(track: MediaInfo) { + }, + addEventListener(event: string, listener: () => void) { + if (!videoElement) { + return; + } + + videoElement.addEventListener(event, listener); + }, + removeEventListener(event: string, listener: () => void) { + if (!videoElement) { + return; + } + + videoElement.removeEventListener(event, listener); + }, + debug(): any { + return videoElement; + } + }), [videoElement]); + + return ( + <video ref={setVideoElement} autoPlay={autoPlay} src={media.src} className={classes.player} + crossOrigin="anonymous" controls> + {previewSrc && ( + <track ref={previewTrackRef} kind="metadata" label="previews" src={previewSrc}/> + )} + </video> + ); +}); + +const useStyles = createUseStyles({ + player: { + maxWidth: "40rem", + margin: { + left: "auto", + right: "auto", + } + } +}); diff --git a/src/routes/player/video/VideoApi.ts b/src/routes/player/video/VideoApi.ts new file mode 100644 index 0000000..fd53529 --- /dev/null +++ b/src/routes/player/video/VideoApi.ts @@ -0,0 +1,35 @@ +import {MediaInfo} from "dashjs"; + +export interface VideoApi { + METADATA_EVENT: string + TIMECHANGE_EVENT: string + + play(): void + + pause(): void + + isPaused(): boolean + + setPlaybackRate(value: number): void + + getPlaybackRate(): number + + setCurrentTime(value: number): void + + getCurrentTime(): number + + getDuration(): number + + canPlay(): boolean + + getAudioTracks(): MediaInfo[] + + setAudioTrack(track: MediaInfo): void + + addEventListener(event: string, listener: () => void): void + + removeEventListener(event: string, listener: () => void): void + + debug(): any + +} diff --git a/src/routes/player/video/VideoElement.tsx b/src/routes/player/video/VideoElement.tsx new file mode 100644 index 0000000..cb2eff0 --- /dev/null +++ b/src/routes/player/video/VideoElement.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import {Media} from "../../../api/models/Media"; +import {DashVideoElement} from "./DashVideoElement"; +import {RawVideoElement} from "./RawVideoElement"; +import {VideoApi} from "./VideoApi"; + +interface Props { + media: Media, + autoPlay: boolean, + previewSrc?: string, + previewTrackRef: React.Ref<HTMLTrackElement> +} + +export const VideoElement = React.forwardRef<VideoApi, Props>(function ( + props: Props, ref +) { + switch (props.media.mime) { + case "application/dash+xml": + return <DashVideoElement ref={ref} {...props} />; + default: + return <RawVideoElement ref={ref} {...props} />; + } +}); diff --git a/src/util/CurrentContentContext.ts b/src/util/CurrentContentContext.ts new file mode 100644 index 0000000..6b39801 --- /dev/null +++ b/src/util/CurrentContentContext.ts @@ -0,0 +1,7 @@ +import {createContext, useContext} from "react"; +import {ContentMeta} from "../api/models/dto/ContentMeta"; + +const CurrentContentContext = createContext<ContentMeta | null>(null); +export const CurrentContentProvider = CurrentContentContext.Provider; +export const CurrentContentConsumer = CurrentContentContext.Consumer; +export const useCurrentContent = () => useContext(CurrentContentContext); diff --git a/src/util/CurrentTitleContext.ts b/src/util/CurrentTitleContext.ts deleted file mode 100644 index 3576580..0000000 --- a/src/util/CurrentTitleContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/util/media/useAudioTracks.ts b/src/util/media/useAudioTracks.ts new file mode 100644 index 0000000..5076217 --- /dev/null +++ b/src/util/media/useAudioTracks.ts @@ -0,0 +1,25 @@ +import {useEffect, useState} from "react"; +import {VideoApi} from "../../routes/player/video/VideoApi"; +import {MediaInfo} from "dashjs"; + +export const useAudioTracks = (video: VideoApi | null) => { + const [audioTracks, setAudioTracks] = useState<MediaInfo[]>([]); + useEffect(() => { + if (video !== null) { + if (video.canPlay()) { + setAudioTracks(video.getAudioTracks()); + } else { + const listener = () => { + window.requestAnimationFrame(() => { + setAudioTracks(video.getAudioTracks()); + }) + }; + video.addEventListener(video.METADATA_EVENT, listener) + return () => { + video.removeEventListener(video.METADATA_EVENT, listener) + } + } + } + }, [video]); + return audioTracks; +} diff --git a/src/util/media/useDebugInfo.ts b/src/util/media/useDebugInfo.ts new file mode 100644 index 0000000..c450f4b --- /dev/null +++ b/src/util/media/useDebugInfo.ts @@ -0,0 +1,22 @@ +import {useEffect} from "react"; + +function onlyInDebug<U, R>(callback: (...args: U[]) => R, defValue?: R): (...args: U[]) => R { + if (process.env.NODE_ENV === "development") { + return callback + } else { + return () => defValue as R; + } +} + +export const useDebugInfo = onlyInDebug((key: string, value: any) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + // @ts-ignore + if (!window.mediaDebug) { + // @ts-ignore + window.mediaDebug = {}; + } + // @ts-ignore + window.mediaDebug[key] = value; + }, [key, value]); +}); diff --git a/src/util/media/useDuration.ts b/src/util/media/useDuration.ts index 42cdb09..8b2d42b 100644 --- a/src/util/media/useDuration.ts +++ b/src/util/media/useDuration.ts @@ -1,20 +1,21 @@ import {useEffect, useState} from "react"; +import {VideoApi} from "../../routes/player/video/VideoApi"; -export const useDuration = (video: HTMLVideoElement | null) => { +export const useDuration = (video: VideoApi | null) => { const [duration, setDuration] = useState<number>(0); useEffect(() => { if (video !== null) { - if (video.readyState >= 1) { - setDuration(video.duration); + if (video.canPlay()) { + setDuration(video.getDuration()); } else { const listener = () => { window.requestAnimationFrame(() => { - setDuration(video.duration); + setDuration(video.getDuration()); }) }; - video.addEventListener("loadedmetadata", listener) + video.addEventListener(video.METADATA_EVENT, listener) return () => { - video.removeEventListener("loadedmetadata", listener) + video.removeEventListener(video.METADATA_EVENT, listener) } } } diff --git a/src/util/media/usePosition.ts b/src/util/media/usePosition.ts index fb1b390..28db74d 100644 --- a/src/util/media/usePosition.ts +++ b/src/util/media/usePosition.ts @@ -1,17 +1,18 @@ import {useEffect, useState} from "react"; +import {VideoApi} from "../../routes/player/video/VideoApi"; -export const usePosition = (video: HTMLVideoElement | null) => { +export const usePosition = (video: VideoApi | null) => { const [position, setPosition] = useState<number>(0); useEffect(() => { if (video !== null) { const listener = () => { window.requestAnimationFrame(() => { - setPosition(video.currentTime); + setPosition(video.getCurrentTime()); }) }; - video.addEventListener("timeupdate", listener) + video.addEventListener(video.TIMECHANGE_EVENT, listener) return () => { - video.removeEventListener("timeupdate", listener) + video.removeEventListener(video.TIMECHANGE_EVENT, listener) } } }, [video]); -- GitLab