From d98768bc58f419803e84bb0ac5f83d0d123a8a49 Mon Sep 17 00:00:00 2001
From: Janne Koschinski <janne@kuschku.de>
Date: Sat, 26 Sep 2020 23:30:22 +0200
Subject: [PATCH] Implement basic seeking

---
 package-lock.json                           | 107 +++++++++++
 package.json                                |   4 +
 src/App.tsx                                 | 100 ++---------
 src/api/ApiClient.ts                        |   5 +-
 src/api/models/Rating.ts                    |   2 +-
 src/api/models/Title.ts                     |  13 +-
 src/api/models/TitleDescription.ts          |   2 +-
 src/api/models/TitleName.ts                 |   2 +-
 src/api/models/dto/TitleMeta.ts             |   7 +
 src/index.tsx                               |   2 +-
 src/routes/TitleRoute.tsx                   |  21 +++
 src/routes/main/MainPage.tsx                |  79 ++++++++
 src/routes/player/Player.tsx                |  63 +++++++
 src/routes/player/PlayerError.tsx           |   7 +
 src/routes/player/PlayerLoading.tsx         |   7 +
 src/routes/player/PlayerPage.tsx            |  23 +++
 src/routes/player/PreviewViewer.tsx         |  23 +++
 src/routes/player/SeekBar.tsx               |  87 +++++++++
 src/util/CurrentTitleContext.ts             |   7 +
 src/{ => util}/locale/Locale.ts             |   0
 src/{ => util}/locale/LocalePriority.ts     |   0
 src/util/locale/LocalizedContext.ts         |  10 ++
 src/{ => util}/locale/LocalizedData.ts      |   0
 src/{ => util}/locale/RegionalData.ts       |   0
 src/{ => util}/locale/selectLocaleData.ts   |   0
 src/{ => util}/locale/selectRegionalData.ts |   0
 src/util/media/MediaFragment.ts             | 189 ++++++++++++++++++++
 src/util/media/useCanvas.ts                 |  16 ++
 src/util/media/useDuration.ts               |  23 +++
 src/util/media/useImage.ts                  |  18 ++
 src/util/media/usePosition.ts               |  19 ++
 src/util/media/useTextTrackCues.ts          |  22 +++
 src/{ => util}/mime/PlayabilityRating.ts    |   0
 src/{ => util}/mime/usePlayabilityRating.ts |  10 +-
 src/{ => util}/mime/videoMimeString.ts      |   2 +-
 src/{ => util}/request/RequestClient.ts     |   0
 src/{ => util}/request/RequestError.ts      |   0
 src/{ => util}/request/RequestErrorKind.ts  |   0
 src/util/{ => sort}/sortLexically.ts        |   0
 src/util/{ => sort}/sortNumerically.ts      |   0
 src/util/sprite/ImageSprite.tsx             |   9 +
 src/util/sprite/parseImageSprite.tsx        |  27 +++
 src/util/sprite/useImageSprite.ts           |  23 +++
 43 files changed, 834 insertions(+), 95 deletions(-)
 create mode 100644 src/api/models/dto/TitleMeta.ts
 create mode 100644 src/routes/TitleRoute.tsx
 create mode 100644 src/routes/main/MainPage.tsx
 create mode 100644 src/routes/player/Player.tsx
 create mode 100644 src/routes/player/PlayerError.tsx
 create mode 100644 src/routes/player/PlayerLoading.tsx
 create mode 100644 src/routes/player/PlayerPage.tsx
 create mode 100644 src/routes/player/PreviewViewer.tsx
 create mode 100644 src/routes/player/SeekBar.tsx
 create mode 100644 src/util/CurrentTitleContext.ts
 rename src/{ => util}/locale/Locale.ts (100%)
 rename src/{ => util}/locale/LocalePriority.ts (100%)
 create mode 100644 src/util/locale/LocalizedContext.ts
 rename src/{ => util}/locale/LocalizedData.ts (100%)
 rename src/{ => util}/locale/RegionalData.ts (100%)
 rename src/{ => util}/locale/selectLocaleData.ts (100%)
 rename src/{ => util}/locale/selectRegionalData.ts (100%)
 create mode 100644 src/util/media/MediaFragment.ts
 create mode 100644 src/util/media/useCanvas.ts
 create mode 100644 src/util/media/useDuration.ts
 create mode 100644 src/util/media/useImage.ts
 create mode 100644 src/util/media/usePosition.ts
 create mode 100644 src/util/media/useTextTrackCues.ts
 rename src/{ => util}/mime/PlayabilityRating.ts (100%)
 rename src/{ => util}/mime/usePlayabilityRating.ts (64%)
 rename src/{ => util}/mime/videoMimeString.ts (70%)
 rename src/{ => util}/request/RequestClient.ts (100%)
 rename src/{ => util}/request/RequestError.ts (100%)
 rename src/{ => util}/request/RequestErrorKind.ts (100%)
 rename src/util/{ => sort}/sortLexically.ts (100%)
 rename src/util/{ => sort}/sortNumerically.ts (100%)
 create mode 100644 src/util/sprite/ImageSprite.tsx
 create mode 100644 src/util/sprite/parseImageSprite.tsx
 create mode 100644 src/util/sprite/useImageSprite.ts

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