Skip to content
Snippets Groups Projects
Verified Commit d98768bc authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

Implement basic seeking

parent cf02a0fb
No related branches found
No related tags found
No related merge requests found
Showing
with 468 additions and 93 deletions
......@@ -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",
......
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();
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;
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 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>
<BrowserRouter>
<Route path="/player/:titleId" exact>
<TitleRoute>
<PlayerPage/>
</TitleRoute>
</Route>
<Route path="/" exact>
<MainPage/>
</Route>
</BrowserRouter>
)
})}
</div>
);
}
const useStyles = createUseStyles({
movie: {
maxWidth: "40rem",
margin: {
left: "auto",
right: "auto",
}
},
poster: {
maxWidth: "20rem",
maxHeight: "20rem",
}
});
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"
});
......
import {LocalizedData} from "../../locale/LocalizedData";
import {LocalizedData} from "../../util/locale/LocalizedData";
export interface Rating extends LocalizedData {
region: string | null,
......
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)
import {LocalizedData} from "../../locale/LocalizedData";
import {LocalizedData} from "../../util/locale/LocalizedData";
export interface TitleDescription extends LocalizedData {
overview: string,
......
import {LocalizedData} from "../../locale/LocalizedData";
import {LocalizedData} from "../../util/locale/LocalizedData";
export interface TitleName extends LocalizedData {
name: string,
......
import {Title} from "../Title";
export interface TitleMeta {
title: Title,
show: Title | null,
episodes: Title[],
}
......@@ -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";
......
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>
)
}
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",
}
});
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",
}
}
});
import React from "react";
export function PlayerError() {
return (
<div>Error</div>
);
}
import React from "react";
export function PlayerLoading() {
return (
<div>Loading</div>
);
}
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}/>);
}
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]);
}
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",
}
});
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);
File moved
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment