From d6a8748a58ed5d85f362a8b80c7a9654f510f9fe Mon Sep 17 00:00:00 2001
From: Janne Koschinski <janne@kuschku.de>
Date: Tue, 5 Jan 2021 00:06:43 +0100
Subject: [PATCH] Cleanup content loading

---
 package-lock.json                            |  23 ++++
 package.json                                 |   1 +
 src/App.tsx                                  |  25 ++--
 src/api/ApiClient.ts                         |  38 ------
 src/api/ApiClientContext.ts                  |  13 --
 src/api/ApiEndpointContext.ts                |  11 ++
 src/api/hooks.ts                             |  30 +++++
 src/api/queries.ts                           |  53 ++++++++
 src/index.tsx                                |   2 +-
 src/routes/ContentRoute.tsx                  |  11 +-
 src/routes/main/MainPage.tsx                 | 121 +++----------------
 src/routes/main/MediaEntry.tsx               |  80 ++++++++++++
 src/routes/main/MediaEpisode.tsx             |  31 +++++
 src/routes/player/Player.tsx                 |  57 +++------
 src/routes/player/video/DashVideoElement.tsx |   2 +
 src/routes/player/video/PlayerApi.ts         |   2 +
 src/routes/player/video/RawVideoElement.tsx  |   2 +
 src/util/media/usePaused.ts                  |  22 ++++
 src/util/parseUrl.ts                         |  11 ++
 19 files changed, 324 insertions(+), 211 deletions(-)
 delete mode 100644 src/api/ApiClient.ts
 delete mode 100644 src/api/ApiClientContext.ts
 create mode 100644 src/api/ApiEndpointContext.ts
 create mode 100644 src/api/hooks.ts
 create mode 100644 src/api/queries.ts
 create mode 100644 src/routes/main/MediaEntry.tsx
 create mode 100644 src/routes/main/MediaEpisode.tsx
 create mode 100644 src/util/media/usePaused.ts
 create mode 100644 src/util/parseUrl.ts

diff --git a/package-lock.json b/package-lock.json
index bbc2a3f..1a50ed0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9327,6 +9327,15 @@
         "object-visit": "^1.0.0"
       }
     },
+    "match-sorter": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.1.0.tgz",
+      "integrity": "sha512-sKPMf4kbF7Dm5Crx0bbfLpokK68PUJ/0STUIOPa1ZmTZEA3lCaPK3gapQR573oLmvdkTfGojzySkIwuq6Z6xRQ==",
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "remove-accents": "0.4.2"
+      }
+    },
     "md5.js": {
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -12022,6 +12031,15 @@
         "tiny-warning": "^1.0.2"
       }
     },
+    "react-query": {
+      "version": "3.5.9",
+      "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.5.9.tgz",
+      "integrity": "sha512-thlxrnl7cDg6qmk+N2ADjDVDJkoU3c7ZFJivYph0XoBDgkRIpb3A+tpqH7o6gu7JXZum9lfX1o294UfYfTiwvg==",
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "match-sorter": "^6.0.2"
+      }
+    },
     "react-refresh": {
       "version": "0.8.3",
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz",
@@ -12363,6 +12381,11 @@
       "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
       "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk="
     },
+    "remove-accents": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+      "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
+    },
     "remove-trailing-separator": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
diff --git a/package.json b/package.json
index 1abea20..ac45e49 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-jss": "^10.5.0",
+    "react-query": "^3.5.9",
     "react-router": "^5.2.0",
     "react-router-dom": "^5.2.0",
     "react-scripts": "4.0.1",
diff --git a/src/App.tsx b/src/App.tsx
index be09b03..4e6359e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,18 +2,23 @@ import {BrowserRouter, Route} from 'react-router-dom';
 import {MainPage} from "./routes/main/MainPage";
 import {PlayerPage} from "./routes/player/PlayerPage";
 import {ContentRoute} from "./routes/ContentRoute";
+import {QueryClient, QueryClientProvider} from "react-query";
+
+const queryClient = new QueryClient();
 
 export function App() {
     return (
-        <BrowserRouter>
-            <Route path="/player/:contentId" exact>
-                <ContentRoute>
-                    <PlayerPage/>
-                </ContentRoute>
-            </Route>
-            <Route path="/" exact>
-                <MainPage/>
-            </Route>
-        </BrowserRouter>
+        <QueryClientProvider client={queryClient}>
+            <BrowserRouter>
+                <Route path="/player/:contentId" exact>
+                    <ContentRoute>
+                        <PlayerPage/>
+                    </ContentRoute>
+                </Route>
+                <Route path="/" exact>
+                    <MainPage/>
+                </Route>
+            </BrowserRouter>
+        </QueryClientProvider>
     )
 }
diff --git a/src/api/ApiClient.ts b/src/api/ApiClient.ts
deleted file mode 100644
index 8bcce03..0000000
--- a/src/api/ApiClient.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {RequestClient} from "../util/request/RequestClient";
-import {GenreWithContent} from "./models/dto/GenreWithContent";
-import {ContentMeta} from "./models/dto/ContentMeta";
-import {Instalment} from "./models/Instalment";
-import {Genre} from "./models/Genre";
-import {Content} from "./models/Content";
-
-export class ApiClient extends RequestClient {
-    public async listGenres(): Promise<Genre[]> {
-        return await this.request(`api/v1/genres`, {
-            method: "GET"
-        });
-    }
-
-    public async getGenre(genreId: string): Promise<GenreWithContent> {
-        return await this.request(`api/v1/genres/${genreId}`, {
-            method: "GET"
-        });
-    }
-
-    public async listContent(): Promise<Content[]> {
-        return await this.request(`api/v1/content`, {
-            method: "GET"
-        });
-    }
-
-    public async getContent(contentId: string): Promise<ContentMeta> {
-        return await this.request(`api/v1/content/${contentId}`, {
-            method: "GET"
-        });
-    }
-
-    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
deleted file mode 100644
index 44218d5..0000000
--- a/src/api/ApiClientContext.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import {createContext, useContext} from "react";
-import {ApiClient} from "./ApiClient";
-
-const ApiClientContext = createContext<ApiClient>(
-    new ApiClient(
-        localStorage.getItem("API_ENDPOINT") ||
-        new URL("/", window.location.href).toString()
-    )
-);
-
-export const ApiClientProvider = ApiClientContext.Provider;
-export const ApiClientConsumer = ApiClientContext.Consumer;
-export const useApiClient = () => useContext<ApiClient>(ApiClientContext);
diff --git a/src/api/ApiEndpointContext.ts b/src/api/ApiEndpointContext.ts
new file mode 100644
index 0000000..5a6b490
--- /dev/null
+++ b/src/api/ApiEndpointContext.ts
@@ -0,0 +1,11 @@
+import {createContext, useContext} from "react";
+import {parseUrl} from "../util/parseUrl";
+
+const ApiEndpointContext = createContext<URL>(
+    parseUrl(localStorage.getItem("API_ENDPOINT"))
+    || new URL("/", window.location.href)
+);
+
+export const ApiEndpointProvider = ApiEndpointContext.Provider;
+export const ApiEndpointConsumer = ApiEndpointContext.Consumer;
+export const useApiEndpoint = () => useContext<URL>(ApiEndpointContext);
diff --git a/src/api/hooks.ts b/src/api/hooks.ts
new file mode 100644
index 0000000..074f081
--- /dev/null
+++ b/src/api/hooks.ts
@@ -0,0 +1,30 @@
+import {Content} from "./models/Content";
+import {useQuery} from "react-query";
+import {getContent, listEpisodes} from "./queries";
+import {Instalment} from "./models/Instalment";
+import {useApiEndpoint} from "./ApiEndpointContext";
+import {ContentMeta} from "./models/dto/ContentMeta";
+
+export function useEpisodes(item: Content | undefined): [Instalment[], boolean, unknown] {
+    const apiEndpoint = useApiEndpoint();
+
+    const {data, isLoading, error} = useQuery(
+        ["episodes", item?.ids?.uuid],
+        () => item?.kind === "show"
+            ? listEpisodes(apiEndpoint, item.ids.uuid)
+            : []
+    )
+
+    return [data || [], isLoading, error];
+}
+
+export function useContent(id: string): [ContentMeta | null, boolean, unknown] {
+    const apiEndpoint = useApiEndpoint();
+
+    const {data, isLoading, error} = useQuery(
+        ["media", id],
+        () => getContent(apiEndpoint, id)
+    )
+
+    return [data || null, isLoading, error];
+}
diff --git a/src/api/queries.ts b/src/api/queries.ts
new file mode 100644
index 0000000..d7ff6f0
--- /dev/null
+++ b/src/api/queries.ts
@@ -0,0 +1,53 @@
+import {Genre} from "./models/Genre";
+import {GenreWithContent} from "./models/dto/GenreWithContent";
+import {Content} from "./models/Content";
+import {ContentMeta} from "./models/dto/ContentMeta";
+import {Instalment} from "./models/Instalment";
+import {RequestErrorKind} from "../util/request/RequestErrorKind";
+import {RequestError} from "../util/request/RequestError";
+
+async function request<T>(
+    baseUrl: URL,
+    path: string,
+    headers?: RequestInit
+): Promise<T> {
+    const url = new URL(path, baseUrl).toString();
+    const response = await fetch(url, headers);
+    if (response.ok) {
+        return await response.json();
+    } else if (response.status in RequestErrorKind) {
+        throw new RequestError(response.statusText, response.status)
+    } else {
+        throw new RequestError(response.statusText, RequestErrorKind.UnknownError)
+    }
+}
+
+export async function listGenres(baseUrl: URL): Promise<Genre[]> {
+    return await request(baseUrl, `api/v1/genres`, {
+        method: "GET"
+    });
+}
+
+export async function getGenre(baseUrl: URL, genreId: string): Promise<GenreWithContent> {
+    return await request(baseUrl, `api/v1/genres/${genreId}`, {
+        method: "GET"
+    });
+}
+
+export async function listContent(baseUrl: URL): Promise<Content[]> {
+    return await request(baseUrl, `api/v1/content`, {
+        method: "GET"
+    });
+}
+
+export async function getContent(baseUrl: URL, contentId: string): Promise<ContentMeta> {
+    return await request(baseUrl, `api/v1/content/${contentId}`, {
+        method: "GET"
+    });
+}
+
+export async function listEpisodes(baseUrl: URL, contentId: string): Promise<Instalment[]> {
+    return await request(baseUrl, `api/v1/content/${contentId}/episodes`, {
+        method: "GET"
+    });
+}
diff --git a/src/index.tsx b/src/index.tsx
index 33c5e0b..2b54e57 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,5 +1,5 @@
 import {produce} from 'immer'
-import { StrictMode } from 'react';
+import {StrictMode} from 'react';
 import ReactDOM from 'react-dom';
 
 import {defaults} from 'react-sweet-state';
diff --git a/src/routes/ContentRoute.tsx b/src/routes/ContentRoute.tsx
index 805a29c..5a9ec8b 100644
--- a/src/routes/ContentRoute.tsx
+++ b/src/routes/ContentRoute.tsx
@@ -1,17 +1,12 @@
-import { PropsWithChildren, useEffect, useState } from "react";
+import {PropsWithChildren} from "react";
 import {useParams} from "react-router";
-import {useApiClient} from "../api/ApiClientContext";
-import {ContentMeta} from "../api/models/dto/ContentMeta";
 import {CurrentContentProvider} from "../util/CurrentContentContext";
+import {useContent} from "../api/hooks";
 
 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]);
+    const [meta/*, isLoading, error*/] = useContent(contentId);
 
     return (
         <CurrentContentProvider value={meta}>
diff --git a/src/routes/main/MainPage.tsx b/src/routes/main/MainPage.tsx
index 18cc5a1..752224f 100644
--- a/src/routes/main/MainPage.tsx
+++ b/src/routes/main/MainPage.tsx
@@ -1,112 +1,25 @@
-import { useEffect, useState } from "react";
-import {createUseStyles} from "react-jss";
-import {Link} from "react-router-dom";
-import {useApiClient} from "../../api/ApiClientContext";
-import {Instalment} from "../../api/models/Instalment";
-import {getLocalizedDescription, getLocalizedName, getLocalizedRating, Content} from "../../api/models/Content";
-import {Locale} from "../../util/locale/Locale";
+import {useApiEndpoint} from "../../api/ApiEndpointContext";
+import {Content, getLocalizedName} from "../../api/models/Content";
 import {sortLexicallyAsc} from "../../util/sort/sortLexically";
+import {useQuery} from "react-query";
+import {listContent} from "../../api/queries";
+import {MediaEntry} from "./MediaEntry";
+import {useLocale} from "../../util/locale/LocalizedContext";
 
-interface Props {
-
-}
-
-export function MainPage(props: Props) {
-    const apiClient = useApiClient();
-    const [data, setData] = useState<Content[]>();
-    useEffect(() => {
-        apiClient.listContent().then(setData);
-    }, [apiClient]);
-    const locale: Locale = {
-        language: "en",
-        region: "DE",
-    };
-    const [episodes, setEpisodes] = useState<{ [key: string]: Instalment[] }>({});
-    const contentToLoad = data?.filter(it => it.kind === "show" && episodes[it.ids.uuid] === undefined);
-    useEffect(() => {
-        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, contentToLoad]);
-
-    const classes = useStyles();
+export function MainPage() {
+    const apiEndpoint = useApiEndpoint();
+    const locale = useLocale();
+    const {data/*, isLoading, isError*/} = useQuery(
+        "media",
+        () => listContent(apiEndpoint)
+    );
+    const media = data || [];
 
     return (
         <div>
-            {data?.sort(sortLexicallyAsc(item => getLocalizedName(item, locale)?.name || ""))
-                .map((item: Content) => {
-                    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>{rating?.certification}</strong></p>
-                            <p><strong>{description?.tagline}</strong></p>
-                            <p>{description?.overview}</p>
-                            {item.kind === "movie" && (
-                                <p>
-                                    <Link to={"/player/" + item.ids.uuid}>Play</Link>
-                                </p>
-                            )}
-                            {item.kind === "show" && (
-                                <ul>
-                                    {episodes[item.ids.uuid]?.map(episode => {
-                                        const episodeTitle = getLocalizedName(episode.content, locale);
-                                        const episodeDescription = getLocalizedDescription(episode.content, locale);
-                                        return (
-                                            <li key={episode.content.ids.uuid}>
-                                                <p>
-                                                    <strong>S{episode.season}E{episode.episode}</strong> – {episode.airDate}
-                                                </p>
-                                                <p><strong>{episodeTitle?.name}</strong></p>
-                                                <p><strong>{episodeDescription?.tagline}</strong></p>
-                                                <p>{episodeDescription?.overview}</p>
-                                                <p>
-                                                    <Link to={"/player/" + episode.content.ids.uuid}>Play</Link>
-                                                </p>
-                                            </li>
-                                        )
-                                    })}
-                                </ul>
-                            )}
-                            {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}
-                                />
-                            )}
-                        </div>
-                    )
-                })}
+            {media
+                .sort(sortLexicallyAsc(item => getLocalizedName(item, locale)?.name || ""))
+                .map((item: Content) => (<MediaEntry item={item}/>))}
         </div>
     );
 }
-
-const useStyles = createUseStyles({
-    movie: {
-        maxWidth: "40rem",
-        margin: {
-            left: "auto",
-            right: "auto",
-        }
-    },
-    poster: {
-        maxWidth: "20rem",
-        maxHeight: "20rem",
-    }
-});
diff --git a/src/routes/main/MediaEntry.tsx b/src/routes/main/MediaEntry.tsx
new file mode 100644
index 0000000..042417c
--- /dev/null
+++ b/src/routes/main/MediaEntry.tsx
@@ -0,0 +1,80 @@
+import {Content, getLocalizedDescription, getLocalizedName, getLocalizedRating} from "../../api/models/Content";
+import {Fragment} from "react";
+import {Link} from "react-router-dom";
+import {createUseStyles} from "react-jss";
+import {useLocale} from "../../util/locale/LocalizedContext";
+import {useEpisodes} from "../../api/hooks";
+import {MediaEpisode} from "./MediaEpisode";
+
+export interface Props {
+    item: Content
+}
+
+export function MediaEntry(
+    {item}: Props
+) {
+    const locale = useLocale();
+    const classes = useStyles();
+
+    const [episodes/*, episodesLoading*/, episodesError] = useEpisodes(item);
+
+    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>{rating?.certification}</strong></p>
+            <p><strong>{description?.tagline}</strong></p>
+            <p>{description?.overview}</p>
+            {item.kind === "movie" && (
+                <p>
+                    <Link to={"/player/" + item.ids.uuid}>Play</Link>
+                </p>
+            )}
+            {item.kind === "show" && (
+                <Fragment>
+                    {episodesError ? (
+                        <p>{""+episodesError}</p>
+                    ) : (
+                        <ul>
+                            {episodes.map(episode =>
+                                <MediaEpisode item={episode} />)}
+                        </ul>
+                    )}
+                </Fragment>
+            )}
+            {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}
+                />
+            )}
+        </div>
+    )
+}
+
+const useStyles = createUseStyles({
+    movie: {
+        maxWidth: "40rem",
+        margin: {
+            left: "auto",
+            right: "auto",
+        }
+    },
+    poster: {
+        maxWidth: "20rem",
+        maxHeight: "20rem",
+    }
+});
diff --git a/src/routes/main/MediaEpisode.tsx b/src/routes/main/MediaEpisode.tsx
new file mode 100644
index 0000000..498b80a
--- /dev/null
+++ b/src/routes/main/MediaEpisode.tsx
@@ -0,0 +1,31 @@
+import {Instalment} from "../../api/models/Instalment";
+import {getLocalizedDescription, getLocalizedName} from "../../api/models/Content";
+import {Link} from "react-router-dom";
+import {useLocale} from "../../util/locale/LocalizedContext";
+
+export interface Props {
+    item: Instalment,
+    disabled?: boolean
+}
+
+export function MediaEpisode({item, disabled}: Props) {
+    const locale = useLocale();
+    const episodeTitle = getLocalizedName(item.content, locale);
+    const episodeDescription = getLocalizedDescription(item.content, locale);
+
+    return (
+        <li key={item.content.ids.uuid}>
+            <p>
+                <strong>S{item.season}E{item.episode}</strong> – {item.airDate}
+            </p>
+            <p><strong>{episodeTitle?.name}</strong></p>
+            <p><strong>{episodeDescription?.tagline}</strong></p>
+            <p>{episodeDescription?.overview}</p>
+            {disabled ? (
+                <p><u>Play</u></p>
+            ) : (
+                <p><Link to={"/player/" + item.content.ids.uuid}>Play</Link></p>
+            )}
+        </li>
+    )
+}
diff --git a/src/routes/player/Player.tsx b/src/routes/player/Player.tsx
index a4cfea0..b529f79 100644
--- a/src/routes/player/Player.tsx
+++ b/src/routes/player/Player.tsx
@@ -1,4 +1,4 @@
-import {Fragment, useEffect, useState} from "react";
+import {Fragment, useState} from "react";
 import {createUseStyles} from "react-jss";
 import {Link} from "react-router-dom";
 import {ContentMeta} from "../../api/models/dto/ContentMeta";
@@ -17,8 +17,9 @@ import {Subtitle} from "../../api/models/Subtitle";
 import {MousePosition} from "../../util/mouse/MousePosition";
 import {VideoProvider} from "./video/VideoContext";
 import {SubtitleRenderer} from "./subtitles/SubtitleRenderer";
-import {useApiClient} from "../../api/ApiClientContext";
-import {Instalment, sortInstalments} from "../../api/models/Instalment";
+import {useEpisodes} from "../../api/hooks";
+import {MediaEpisode} from "../main/MediaEpisode";
+import {usePaused} from "../../util/media/usePaused";
 
 interface Props {
     meta: ContentMeta,
@@ -28,19 +29,11 @@ interface Props {
 export function Player(
     {meta, media}: Props
 ) {
-    const apiClient = useApiClient();
     const {content, instalment} = meta;
     const classes = useStyles();
     const locale = useLocale();
 
-    const [relatedInstalments, setRelatedInstalments] = useState<Instalment[]>([]);
-    useEffect(() => {
-        setRelatedInstalments([]);
-        if (instalment?.content.ids.uuid !== undefined) {
-            apiClient.listEpisodes(instalment.content.ids.uuid)
-                .then(result => setRelatedInstalments(sortInstalments(result)));
-        }
-    }, [apiClient, instalment]);
+    const [relatedEpisodes/*, relatedEpisodesLoading*/, relatedEpisodesError] = useEpisodes(instalment?.content);
 
     const name = getLocalizedName(content, locale);
     const description = getLocalizedDescription(content, locale);
@@ -51,6 +44,7 @@ export function Player(
     const [playerApi, setPlayerApi] = useState<PlayerApi | null>(null);
     const [mousePosition, setMousePosition] = useState<MousePosition | null>(null);
 
+    const paused = usePaused(playerApi);
     const position = usePosition(playerApi);
     const duration = useDuration(playerApi);
     const [audioTracks, currentTrack, setCurrentTrack] = useAudioTracks(playerApi);
@@ -73,29 +67,18 @@ export function Player(
                 {instalment?.content && (
                     <Fragment>
                         <h3>{getLocalizedName(instalment.content, locale)?.name}</h3>
-                        <ol>
-                            {relatedInstalments.map(episode => {
-                                const episodeTitle = getLocalizedName(episode.content, locale);
-                                const episodeDescription = getLocalizedDescription(episode.content, locale);
-                                return (
-                                    <li key={episode.content.ids.uuid}>
-                                        <p>
-                                            <strong>S{episode.season}E{episode.episode}</strong> – {episode.airDate}
-                                        </p>
-                                        <p><strong>{episodeTitle?.name}</strong></p>
-                                        <p><strong>{episodeDescription?.tagline}</strong></p>
-                                        <p>{episodeDescription?.overview}</p>
-                                        {episode.content.ids.uuid !== meta.content.ids.uuid ? (
-                                            <p>
-                                                <Link to={"/player/" + episode.content.ids.uuid}>Play</Link>
-                                            </p>
-                                        ) : (
-                                            <p><u>Playing</u></p>
-                                        )}
-                                    </li>
-                                )
-                            })}
-                        </ol>
+                        {relatedEpisodesError ? (
+                            <p>{"" + relatedEpisodesError}</p>
+                        ) : (
+                            <ul>
+                                {relatedEpisodes.map(episode =>
+                                    <MediaEpisode
+                                        item={episode}
+                                        disabled={episode.content.ids.uuid === content.ids.uuid}
+                                    />
+                                )}
+                            </ul>
+                        )}
                     </Fragment>
                 )}
                 <div className={classes.player}>
@@ -119,13 +102,13 @@ export function Player(
                 </p>
                 <button
                     onClick={playerApi?.play}
-                    disabled={playerApi?.isPaused() === false}
+                    disabled={!paused}
                 >
                     Play
                 </button>
                 <button
                     onClick={playerApi?.pause}
-                    disabled={playerApi?.isPaused() === true}
+                    disabled={paused}
                 >
                     Pause
                 </button>
diff --git a/src/routes/player/video/DashVideoElement.tsx b/src/routes/player/video/DashVideoElement.tsx
index afa2f81..3f7c0a6 100644
--- a/src/routes/player/video/DashVideoElement.tsx
+++ b/src/routes/player/video/DashVideoElement.tsx
@@ -19,6 +19,8 @@ export const DashVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(
     useImperativeHandle(ref, () => ({
         METADATA_EVENT: dashjs.MediaPlayer.events.PLAYBACK_METADATA_LOADED,
         TIMECHANGE_EVENT: dashjs.MediaPlayer.events.PLAYBACK_TIME_UPDATED,
+        PLAY_EVENT: dashjs.MediaPlayer.events.PLAYBACK_PLAYING,
+        PAUSE_EVENT: dashjs.MediaPlayer.events.PLAYBACK_PAUSED,
         play() {
             if (!player.isReady()) {
                 return;
diff --git a/src/routes/player/video/PlayerApi.ts b/src/routes/player/video/PlayerApi.ts
index bf11227..48ff51b 100644
--- a/src/routes/player/video/PlayerApi.ts
+++ b/src/routes/player/video/PlayerApi.ts
@@ -3,6 +3,8 @@ import {MediaInfo} from "dashjs";
 export interface PlayerApi {
     METADATA_EVENT: string
     TIMECHANGE_EVENT: string
+    PLAY_EVENT: string
+    PAUSE_EVENT: string
 
     play(): void
 
diff --git a/src/routes/player/video/RawVideoElement.tsx b/src/routes/player/video/RawVideoElement.tsx
index b378ebd..5b1765c 100644
--- a/src/routes/player/video/RawVideoElement.tsx
+++ b/src/routes/player/video/RawVideoElement.tsx
@@ -18,6 +18,8 @@ export const RawVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(f
     useImperativeHandle(ref, () => ({
         METADATA_EVENT: "loadedmetadata",
         TIMECHANGE_EVENT: "timeupdate",
+        PLAY_EVENT: "play",
+        PAUSE_EVENT: "pause",
         play() {
             if (!videoElement) {
                 return;
diff --git a/src/util/media/usePaused.ts b/src/util/media/usePaused.ts
new file mode 100644
index 0000000..019e98a
--- /dev/null
+++ b/src/util/media/usePaused.ts
@@ -0,0 +1,22 @@
+import {useEffect, useState} from "react";
+import {PlayerApi} from "../../routes/player/video/PlayerApi";
+
+export const usePaused = (video: PlayerApi | null) => {
+    const [paused, setPaused] = useState<boolean>(false);
+    useEffect(() => {
+        if (video !== null) {
+            const listener = () => {
+                window.requestAnimationFrame(() => {
+                    setPaused(video.isPaused());
+                })
+            };
+            video.addEventListener(video.PLAY_EVENT, listener)
+            video.addEventListener(video.PAUSE_EVENT, listener)
+            return () => {
+                video.removeEventListener(video.PLAY_EVENT, listener)
+                video.removeEventListener(video.PAUSE_EVENT, listener)
+            }
+        }
+    }, [video]);
+    return paused;
+}
diff --git a/src/util/parseUrl.ts b/src/util/parseUrl.ts
new file mode 100644
index 0000000..55e1b29
--- /dev/null
+++ b/src/util/parseUrl.ts
@@ -0,0 +1,11 @@
+export function parseUrl(url: string | null | undefined): URL | null {
+    if (url === null || url === undefined || url === "") {
+        return null;
+    }
+
+    try {
+        return new URL(url);
+    } catch (_) {
+        return null;
+    }
+}
-- 
GitLab