From 8eb7a305759661e8786f30cd558e0d7e6474f38f Mon Sep 17 00:00:00 2001
From: Janne Koschinski <janne@kuschku.de>
Date: Fri, 15 Jan 2021 15:27:00 +0100
Subject: [PATCH] Fix unnecessary re-renders

---
 src/api/models/Image.ts                       | 21 ++++++
 src/routes/main/MediaEntry.tsx                | 35 +++------
 src/routes/main/MediaEpisode.tsx              | 24 +++++++
 src/routes/player/Player.tsx                  | 71 ++++++++++++++++---
 src/routes/player/PlayerPage.tsx              |  2 +
 src/routes/player/PreviewBar.tsx              |  1 +
 src/routes/player/PreviewViewer.tsx           | 25 ++++---
 src/routes/player/SeekBar.tsx                 |  7 +-
 .../{AssRenderer.tsx => SsaRenderer.tsx}      |  6 +-
 .../player/subtitles/SubtitleRenderer.tsx     |  4 +-
 src/routes/player/video/DashVideoElement.tsx  | 38 +++++++---
 src/routes/player/video/PlayerApi.ts          |  7 +-
 src/routes/player/video/RawVideoElement.tsx   | 30 ++++++--
 src/util/media/useAudioTracks.ts              |  4 +-
 .../{usePosition.ts => useCurrentTime.ts}     |  2 +-
 src/util/media/useDuration.ts                 | 30 +++-----
 src/util/media/usePaused.ts                   | 28 +++-----
 src/util/media/usePlayerState.ts              | 30 ++++++++
 src/util/media/useVideoApi.ts                 | 41 +++++++++++
 .../{ass => ssa}/SubtitlesOctopus.ts          |  0
 .../{ass => ssa}/SubtitlesOctopusOptions.ts   |  0
 .../{ass => ssa}/browserHasAlphaBug.ts        |  0
 .../browserSupportsWebAssembly.ts             |  0
 23 files changed, 295 insertions(+), 111 deletions(-)
 rename src/routes/player/subtitles/{AssRenderer.tsx => SsaRenderer.tsx} (89%)
 rename src/util/media/{usePosition.ts => useCurrentTime.ts} (91%)
 create mode 100644 src/util/media/usePlayerState.ts
 create mode 100644 src/util/media/useVideoApi.ts
 rename src/util/subtitles/{ass => ssa}/SubtitlesOctopus.ts (100%)
 rename src/util/subtitles/{ass => ssa}/SubtitlesOctopusOptions.ts (100%)
 rename src/util/subtitles/{ass => ssa}/browserHasAlphaBug.ts (100%)
 rename src/util/subtitles/{ass => ssa}/browserSupportsWebAssembly.ts (100%)

diff --git a/src/api/models/Image.ts b/src/api/models/Image.ts
index 351160b..62511e9 100644
--- a/src/api/models/Image.ts
+++ b/src/api/models/Image.ts
@@ -4,3 +4,24 @@ export interface Image {
     language: string,
     src: string
 }
+
+export function findImage(
+    images: Image[],
+    type: string,
+    languages: (string | null)[]
+): Image | null {
+    const imageList = images.filter(it => it.kind === type)
+
+    for (let language of languages) {
+        const image = imageList.find(it => it.language === language);
+        if (image) {
+            return image;
+        }
+    }
+
+    if (languages.includes(null) && imageList.length) {
+        return imageList[0];
+    }
+
+    return null;
+}
diff --git a/src/routes/main/MediaEntry.tsx b/src/routes/main/MediaEntry.tsx
index 5b5402f..ad87bae 100644
--- a/src/routes/main/MediaEntry.tsx
+++ b/src/routes/main/MediaEntry.tsx
@@ -5,33 +5,12 @@ import {createUseStyles} from "react-jss";
 import {useLocale} from "../../util/locale/LocalizedContext";
 import {useShowEpisodes} from "../../api/ApiHooks";
 import {MediaEpisode} from "./MediaEpisode";
-import {Image} from "../../api/models/Image";
+import {findImage} from "../../api/models/Image";
 
 export interface Props {
     item: Content
 }
 
-function findImage(
-    images: Image[],
-    type: string,
-    languages: (string | null)[]
-): Image | null {
-    const imageList = images.filter(it => it.kind === type)
-
-    for (let language of languages) {
-        const image = imageList.find(it => it.language === language);
-        if (image) {
-            return image;
-        }
-    }
-
-    if (languages.includes(null) && imageList.length) {
-        return imageList[0];
-    }
-
-    return null;
-}
-
 export function MediaEntry(
     {item}: Props
 ) {
@@ -43,10 +22,11 @@ export function MediaEntry(
     const title = getLocalizedName(item, locale);
     const description = getLocalizedDescription(item, locale);
     const rating = getLocalizedRating(item, locale);
-    const [logo, poster, backdrop] = useMemo(() => [
+    const [logo, poster, backdrop, still] = useMemo(() => [
         findImage(item.images, "logo", [locale.language]),
         findImage(item.images, "poster", [locale.language, item.originalLanguage, null]),
-        findImage(item.images, "backdrop", [locale.language, item.originalLanguage, null])
+        findImage(item.images, "backdrop", [locale.language, item.originalLanguage, null]),
+        findImage(item.images, "still", [null, locale.language, item.originalLanguage])
     ], [item.images, item.originalLanguage, locale.language]);
 
     return (
@@ -96,6 +76,13 @@ export function MediaEntry(
                     src={backdrop.src}
                 />
             )}
+            {still && (
+                <img
+                    className={classes.poster}
+                    alt={`Movie still for ${title?.name}`}
+                    src={still.src}
+                />
+            )}
         </div>
     )
 }
diff --git a/src/routes/main/MediaEpisode.tsx b/src/routes/main/MediaEpisode.tsx
index ec815e1..c171c08 100644
--- a/src/routes/main/MediaEpisode.tsx
+++ b/src/routes/main/MediaEpisode.tsx
@@ -2,6 +2,9 @@ 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";
+import {useMemo} from "react";
+import {findImage} from "../../api/models/Image";
+import {createUseStyles} from "react-jss";
 
 export interface Props {
     item: Instalment,
@@ -9,9 +12,16 @@ export interface Props {
 }
 
 export function MediaEpisode({item, disabled}: Props) {
+    const classes = useStyles();
     const locale = useLocale();
     const episodeTitle = getLocalizedName(item.content, locale);
     const episodeDescription = getLocalizedDescription(item.content, locale);
+    const [backdrop, still] = useMemo(() => [
+        findImage(item.content.images, "backdrop", [locale.language, item.content.originalLanguage, null]),
+        findImage(item.content.images, "still", [null, locale.language, item.content.originalLanguage])
+    ], [item.content.images, item.content.originalLanguage, locale.language]);
+
+    const episodeImage = still || backdrop;
 
     return (
         <li>
@@ -21,6 +31,13 @@ export function MediaEpisode({item, disabled}: Props) {
             <p><strong>{episodeTitle?.name}</strong></p>
             <p><strong>{episodeDescription?.tagline}</strong></p>
             <p>{episodeDescription?.overview}</p>
+            {episodeImage && (
+                <img
+                    className={classes.poster}
+                    alt={episodeTitle?.name}
+                    src={episodeImage.src}
+                />
+            )}
             {disabled ? (
                 <p><u>Play</u></p>
             ) : (
@@ -29,3 +46,10 @@ export function MediaEpisode({item, disabled}: Props) {
         </li>
     )
 }
+
+const useStyles = createUseStyles({
+    poster: {
+        maxWidth: "20rem",
+        maxHeight: "20rem",
+    }
+});
diff --git a/src/routes/player/Player.tsx b/src/routes/player/Player.tsx
index 40f04b5..b6936f0 100644
--- a/src/routes/player/Player.tsx
+++ b/src/routes/player/Player.tsx
@@ -1,4 +1,4 @@
-import {Fragment, useState} from "react";
+import {ChangeEvent, Fragment, useCallback, useState} from "react";
 import {createUseStyles} from "react-jss";
 import {Link} from "react-router-dom";
 import {ContentMeta} from "../../api/models/dto/ContentMeta";
@@ -6,8 +6,6 @@ import {Media} from "../../api/models/Media";
 import {getLocalizedDescription, getLocalizedName, getLocalizedRating} from "../../api/models/Content";
 import {formatDuration} from "../../util/formatDuration";
 import {useLocale} from "../../util/locale/LocalizedContext";
-import {useDuration} from "../../util/media/useDuration";
-import {usePosition} from "../../util/media/usePosition";
 import {SeekBar} from "./SeekBar";
 import {VideoElement} from "./video/VideoElement";
 import {PlayerApi} from "./video/PlayerApi";
@@ -20,6 +18,8 @@ import {SubtitleRenderer} from "./subtitles/SubtitleRenderer";
 import {useShowEpisodes} from "../../api/ApiHooks";
 import {MediaEpisode} from "../main/MediaEpisode";
 import {usePaused} from "../../util/media/usePaused";
+import {useCurrentTime} from "../../util/media/useCurrentTime";
+import {useDuration} from "../../util/media/useDuration";
 
 interface Props {
     meta: ContentMeta,
@@ -27,8 +27,11 @@ interface Props {
 }
 
 export function Player(
-    {meta, media}: Props
+    props: Props
 ) {
+    console.log("Rendering Player")
+    const {meta, media} = props;
+
     const {content, instalment} = meta;
     const classes = useStyles();
     const locale = useLocale();
@@ -44,18 +47,43 @@ export function Player(
     const [playerApi, setPlayerApi] = useState<PlayerApi | null>(null);
     const [mousePosition, setMousePosition] = useState<MousePosition | null>(null);
 
+    const [volume, setVolume] = useState<number>(1);
     const paused = usePaused(playerApi);
-    const position = usePosition(playerApi);
+    const currentTime = useCurrentTime(playerApi)
     const duration = useDuration(playerApi);
     const [audioTracks, currentTrack, setCurrentTrack] = useAudioTracks(playerApi);
 
     useDebugInfo("player", playerApi);
     useDebugInfo("content", content);
     useDebugInfo("subtitle", subtitle);
-    useDebugInfo("position", position);
+    useDebugInfo("currentTime", currentTime);
     useDebugInfo("duration", duration);
     useDebugInfo("audioTracks", audioTracks);
 
+    const onPause = useCallback(() => {
+        if (playerApi) {
+            playerApi.pause()
+        }
+    }, [playerApi]);
+
+    const onPlay = useCallback(() => {
+        if (playerApi) {
+            playerApi.play()
+        }
+    }, [playerApi]);
+
+    const onFastForward = useCallback(() => {
+        if (playerApi) {
+            playerApi.setCurrentTime(playerApi.getCurrentTime() + 10)
+        }
+    }, [playerApi]);
+
+    const onRewind = useCallback(() => {
+        if (playerApi) {
+            playerApi.setCurrentTime(playerApi.getCurrentTime() - 10)
+        }
+    }, [playerApi]);
+
     return (
         <VideoProvider value={playerApi?.getVideoElement() || null}>
             <div>
@@ -73,6 +101,7 @@ export function Player(
                             <ul>
                                 {relatedEpisodes.map(episode =>
                                     <MediaEpisode
+                                        key={episode.content.ids.uuid}
                                         item={episode}
                                         disabled={episode.content.ids.uuid === content.ids.uuid}
                                     />
@@ -98,20 +127,42 @@ export function Player(
                     </div>
                 </div>
                 <p style={{fontVariant: "tabular-nums"}}>
-                    {formatDuration(mousePosition ? (mousePosition.relative * duration) : position)} / {formatDuration(duration)}
+                    {formatDuration(mousePosition ? (mousePosition.relative * duration) : currentTime)} / {formatDuration(duration)}
                 </p>
                 <button
-                    onClick={playerApi?.play}
+                    onClick={onPlay}
                     disabled={!paused}
                 >
                     Play
                 </button>
                 <button
-                    onClick={playerApi?.pause}
+                    onClick={onPause}
                     disabled={paused}
                 >
                     Pause
                 </button>
+                <button
+                    onClick={onRewind}
+                >
+                    Rewind
+                </button>
+                <button
+                    onClick={onFastForward}
+                >
+                    Fast Forward
+                </button>
+                <p>
+                    <input
+                        type="range"
+                        min="0"
+                        max="100"
+                        value={volume * 100}
+                        onChange={(event: ChangeEvent<HTMLInputElement>) => {
+                            setVolume(event.target.valueAsNumber / 100);
+                            playerApi?.setVolume(event.target.valueAsNumber / 100)
+                        }}
+                    />
+                </p>
                 <h3>Audio</h3>
                 <ul>
                     {audioTracks.map(track => (
@@ -151,7 +202,7 @@ export function Player(
                     mousePosition={mousePosition}
                     setMousePosition={setMousePosition}
                     duration={duration}
-                    position={position}
+                    position={currentTime}
                 />
             </div>
         </VideoProvider>
diff --git a/src/routes/player/PlayerPage.tsx b/src/routes/player/PlayerPage.tsx
index d65c9eb..6feaaad 100644
--- a/src/routes/player/PlayerPage.tsx
+++ b/src/routes/player/PlayerPage.tsx
@@ -6,6 +6,8 @@ import {PlayerError} from "./PlayerError";
 import {PlayerLoading} from "./PlayerLoading";
 
 export function PlayerPage() {
+    console.log("Rendering player page");
+
     const content = useCurrentContent();
     const playabilityRating = usePlayabilityRating();
 
diff --git a/src/routes/player/PreviewBar.tsx b/src/routes/player/PreviewBar.tsx
index a3dee23..58dd9fc 100644
--- a/src/routes/player/PreviewBar.tsx
+++ b/src/routes/player/PreviewBar.tsx
@@ -13,6 +13,7 @@ interface Props {
 }
 
 export function PreviewBar({previewSrc, duration, position, hidden}: Props) {
+    //console.log("Rendering PreviewBar")
     const classes = useStyles();
 
     const [previewTrack, setPreviewTrack] = useState<HTMLTrackElement | null>(null);
diff --git a/src/routes/player/PreviewViewer.tsx b/src/routes/player/PreviewViewer.tsx
index 4649bb4..71ba1a3 100644
--- a/src/routes/player/PreviewViewer.tsx
+++ b/src/routes/player/PreviewViewer.tsx
@@ -12,6 +12,8 @@ interface Props {
 }
 
 export function PreviewViewer({previewTrack, position}: Props) {
+    //console.log("Rendering PreviewViewer#1")
+
     const classes = useStyles();
 
     const cues = useTextTrackCues(previewTrack);
@@ -25,16 +27,19 @@ export function PreviewViewer({previewTrack, position}: Props) {
         url.hash = "";
         return url.toString();
     }))), [cues, previewTrack]);
-    return useMemo(() => (
-        <Fragment>
-            <HeadPortal>
-                {sources?.map(it => (
-                    <link key={it} rel="preload" href={it} as="image" crossOrigin="anonymous"/>
-                ))}
-            </HeadPortal>
-            <img alt="" className={classes.preview} src={sprite || undefined}/>
-        </Fragment>
-    ), [classes.preview, sources, sprite]);
+    return useMemo(() => {
+        //console.log("Rendering PreviewViewer#2")
+        return (
+            <Fragment>
+                <HeadPortal>
+                    {sources?.map(it => (
+                        <link key={it} rel="preload" href={it} as="image" crossOrigin="anonymous"/>
+                    ))}
+                </HeadPortal>
+                <img alt="" className={classes.preview} src={sprite || undefined}/>
+            </Fragment>
+        )
+    }, [classes.preview, sources, sprite]);
 }
 
 const useStyles = createUseStyles({
diff --git a/src/routes/player/SeekBar.tsx b/src/routes/player/SeekBar.tsx
index 2a2de12..315d531 100644
--- a/src/routes/player/SeekBar.tsx
+++ b/src/routes/player/SeekBar.tsx
@@ -42,8 +42,11 @@ export function SeekBar({videoApi, previewSrc, mousePosition, setMousePosition,
 
     const onMouseMove = useCallback((event: MouseEvent<HTMLDivElement>) => {
         const position = getMousePosition(event);
-        setMousePosition(position);
-    }, [setMousePosition]);
+        const visible = position !== null;
+        if (visible || isVisible !== visible) {
+            setMousePosition(position);
+        }
+    }, [isVisible, setMousePosition]);
 
     const seekHead = useMemo(() => (
         <div
diff --git a/src/routes/player/subtitles/AssRenderer.tsx b/src/routes/player/subtitles/SsaRenderer.tsx
similarity index 89%
rename from src/routes/player/subtitles/AssRenderer.tsx
rename to src/routes/player/subtitles/SsaRenderer.tsx
index 037280b..f64d31a 100644
--- a/src/routes/player/subtitles/AssRenderer.tsx
+++ b/src/routes/player/subtitles/SsaRenderer.tsx
@@ -1,6 +1,6 @@
 import {useEffect, useState} from "react";
 import {Subtitle} from "../../../api/models/Subtitle";
-import SubtitlesOctopus from "../../../util/subtitles/ass/SubtitlesOctopus";
+import SubtitlesOctopus from "../../../util/subtitles/ssa/SubtitlesOctopus";
 
 interface Props {
     videoElement: HTMLVideoElement | null,
@@ -9,7 +9,7 @@ interface Props {
     className?: string,
 }
 
-export function AssRenderer(
+export function SsaRenderer(
     {videoElement, subtitle, className}: Props
 ): JSX.Element {
     const [subtitleCanvas, setSubtitleCanvas] = useState<HTMLCanvasElement | null>(null);
@@ -24,7 +24,7 @@ export function AssRenderer(
                 legacyWorkerUrl: '/assets/js/subtitles-octopus-worker-legacy.js',
                 lossyRender: true,
                 onError: (error: any) => {
-                    console.log("Error rendering ass subtitles:", error);
+                    console.log("Error rendering SSA subtitles:", error);
                 },
                 onReady: () => {
                     instance.setCurrentTime(videoElement?.currentTime);
diff --git a/src/routes/player/subtitles/SubtitleRenderer.tsx b/src/routes/player/subtitles/SubtitleRenderer.tsx
index 4439f3b..8c3a227 100644
--- a/src/routes/player/subtitles/SubtitleRenderer.tsx
+++ b/src/routes/player/subtitles/SubtitleRenderer.tsx
@@ -1,7 +1,7 @@
 import {Subtitle} from "../../../api/models/Subtitle";
 import {Fragment} from "react";
 import {TtmlRenderer} from "./TtmlRenderer";
-import {AssRenderer} from "./AssRenderer";
+import {SsaRenderer} from "./SsaRenderer";
 
 interface Props {
     videoElement: HTMLVideoElement | null,
@@ -17,7 +17,7 @@ export function SubtitleRenderer(
         case "ttml":
             return (<TtmlRenderer {...props} />);
         case "ass":
-            return (<AssRenderer {...props} />);
+            return (<SsaRenderer {...props} />);
         default:
             return (<Fragment/>);
     }
diff --git a/src/routes/player/video/DashVideoElement.tsx b/src/routes/player/video/DashVideoElement.tsx
index 3f7c0a6..227ec5b 100644
--- a/src/routes/player/video/DashVideoElement.tsx
+++ b/src/routes/player/video/DashVideoElement.tsx
@@ -1,4 +1,4 @@
-import {forwardRef, PropsWithChildren, useEffect, useImperativeHandle, useMemo, useState,} from "react";
+import {forwardRef, PropsWithChildren, useEffect, useImperativeHandle, useRef, useState,} from "react";
 import {Media} from "../../../api/models/Media";
 import dashjs, {MediaInfo} from "dashjs";
 import {PlayerApi} from "./PlayerApi";
@@ -13,33 +13,35 @@ export const DashVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(
     {media, autoPlay, className, children},
     ref
 ) {
-    const player = useMemo(() => dashjs.MediaPlayer().create(), []);
+    const playerContainer = useRef<dashjs.MediaPlayerClass>(dashjs.MediaPlayer().create());
+    const player = playerContainer.current;
     const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
 
     useImperativeHandle(ref, () => ({
-        METADATA_EVENT: dashjs.MediaPlayer.events.PLAYBACK_METADATA_LOADED,
+        DURATIONCHANGE_EVENT: dashjs.MediaPlayer.events.PLAYBACK_METADATA_LOADED,
+        CANPLAY_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() {
+        play(): void {
             if (!player.isReady()) {
                 return;
             }
             player.play();
         },
-        pause() {
+        pause(): void {
             if (!player.isReady()) {
                 return;
             }
             player.pause();
         },
-        isPaused() {
+        isPaused(): boolean {
             if (!player.isReady()) {
                 return true;
             }
             return player.isPaused();
         },
-        setPlaybackRate(value: number) {
+        setPlaybackRate(value: number): void {
             if (!player.isReady()) {
                 return;
             }
@@ -51,12 +53,26 @@ export const DashVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(
             }
             return player.getPlaybackRate();
         },
-        setCurrentTime(value: number) {
+        setCurrentTime(value: number): void {
             if (!player.isReady()) {
                 return;
             }
             player.seek(value);
         },
+        setVolume(value: number): void {
+            if (!videoElement) {
+                return;
+            }
+
+            videoElement.volume = value;
+        },
+        getVolume(): number {
+            if (!videoElement) {
+                return 0;
+            }
+
+            return videoElement.volume;
+        },
         getCurrentTime(): number {
             if (!player.isReady()) {
                 return 0;
@@ -90,7 +106,7 @@ export const DashVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(
 
             return player.getCurrentTrackFor("audio");
         },
-        setCurrentAudioTrack(track: MediaInfo) {
+        setCurrentAudioTrack(track: MediaInfo): void {
             if (!player.isReady()) {
                 return;
             }
@@ -99,10 +115,10 @@ export const DashVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(
             }
             player.setCurrentTrack(track);
         },
-        addEventListener(event: string, listener: () => void) {
+        addEventListener(event: string, listener: () => void): void {
             player.on(event, listener);
         },
-        removeEventListener(event: string, listener: () => void) {
+        removeEventListener(event: string, listener: () => void): void {
             player.off(event, listener);
         },
         debug(): any {
diff --git a/src/routes/player/video/PlayerApi.ts b/src/routes/player/video/PlayerApi.ts
index 48ff51b..728a0c0 100644
--- a/src/routes/player/video/PlayerApi.ts
+++ b/src/routes/player/video/PlayerApi.ts
@@ -1,7 +1,8 @@
 import {MediaInfo} from "dashjs";
 
 export interface PlayerApi {
-    METADATA_EVENT: string
+    DURATIONCHANGE_EVENT: string
+    CANPLAY_EVENT: string
     TIMECHANGE_EVENT: string
     PLAY_EVENT: string
     PAUSE_EVENT: string
@@ -22,6 +23,10 @@ export interface PlayerApi {
 
     getDuration(): number
 
+    setVolume(value: number): void
+
+    getVolume(): number
+
     canPlay(): boolean
 
     getAudioTracks(): MediaInfo[]
diff --git a/src/routes/player/video/RawVideoElement.tsx b/src/routes/player/video/RawVideoElement.tsx
index 5b1765c..7a2955c 100644
--- a/src/routes/player/video/RawVideoElement.tsx
+++ b/src/routes/player/video/RawVideoElement.tsx
@@ -16,18 +16,20 @@ export const RawVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(f
     const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
 
     useImperativeHandle(ref, () => ({
-        METADATA_EVENT: "loadedmetadata",
+        DURATIONCHANGE_EVENT: "durationchange",
+        CANPLAY_EVENT: "canplay",
         TIMECHANGE_EVENT: "timeupdate",
         PLAY_EVENT: "play",
         PAUSE_EVENT: "pause",
-        play() {
+
+        play(): void {
             if (!videoElement) {
                 return;
             }
 
             videoElement.play();
         },
-        pause() {
+        pause(): void {
             if (!videoElement) {
                 return;
             }
@@ -41,7 +43,7 @@ export const RawVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(f
 
             return videoElement.paused;
         },
-        setPlaybackRate(value: number) {
+        setPlaybackRate(value: number): void {
             if (!videoElement) {
                 return;
             }
@@ -55,7 +57,21 @@ export const RawVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(f
 
             return videoElement.playbackRate;
         },
-        setCurrentTime(value: number) {
+        setVolume(value: number): void {
+            if (!videoElement) {
+                return;
+            }
+
+            videoElement.volume = value;
+        },
+        getVolume(): number {
+            if (!videoElement) {
+                return 0;
+            }
+
+            return videoElement.volume;
+        },
+        setCurrentTime(value: number): void {
             if (!videoElement) {
                 return;
             }
@@ -91,14 +107,14 @@ export const RawVideoElement = forwardRef<PlayerApi, PropsWithChildren<Props>>(f
         getCurrentAudioTrack(): MediaInfo | null {
             return null;
         },
-        addEventListener(event: string, listener: () => void) {
+        addEventListener(event: string, listener: () => void): void {
             if (!videoElement) {
                 return;
             }
 
             videoElement.addEventListener(event, listener);
         },
-        removeEventListener(event: string, listener: () => void) {
+        removeEventListener(event: string, listener: () => void): void {
             if (!videoElement) {
                 return;
             }
diff --git a/src/util/media/useAudioTracks.ts b/src/util/media/useAudioTracks.ts
index ca9ee8f..c08a1af 100644
--- a/src/util/media/useAudioTracks.ts
+++ b/src/util/media/useAudioTracks.ts
@@ -26,9 +26,9 @@ export function useAudioTracks(video: PlayerApi | null):
             setAudioTracks(video.getAudioTracks());
             setCurrentTrack(video.getCurrentAudioTrack);
         } else {
-            video?.addEventListener(video.METADATA_EVENT, listener)
+            video?.addEventListener(video.CANPLAY_EVENT, listener)
             return () => {
-                video?.removeEventListener(video.METADATA_EVENT, listener)
+                video?.removeEventListener(video.CANPLAY_EVENT, listener)
             }
         }
     }, [listener, video]);
diff --git a/src/util/media/usePosition.ts b/src/util/media/useCurrentTime.ts
similarity index 91%
rename from src/util/media/usePosition.ts
rename to src/util/media/useCurrentTime.ts
index d8a3b8a..ead1f43 100644
--- a/src/util/media/usePosition.ts
+++ b/src/util/media/useCurrentTime.ts
@@ -1,7 +1,7 @@
 import {useEffect, useState} from "react";
 import {PlayerApi} from "../../routes/player/video/PlayerApi";
 
-export const usePosition = (video: PlayerApi | null) => {
+export const useCurrentTime = (video: PlayerApi | null) => {
     const [position, setPosition] = useState<number>(0);
     useEffect(() => {
         if (video !== null) {
diff --git a/src/util/media/useDuration.ts b/src/util/media/useDuration.ts
index c0259a3..52bb3c7 100644
--- a/src/util/media/useDuration.ts
+++ b/src/util/media/useDuration.ts
@@ -1,24 +1,14 @@
-import {useEffect, useState} from "react";
 import {PlayerApi} from "../../routes/player/video/PlayerApi";
+import {useVideoApi} from "./useVideoApi";
+import {useCallback} from "react";
 
 export const useDuration = (video: PlayerApi | null) => {
-    const [duration, setDuration] = useState<number>(0);
-    useEffect(() => {
-        if (video !== null) {
-            if (video.canPlay()) {
-                setDuration(video.getDuration());
-            } else {
-                const listener = () => {
-                    window.requestAnimationFrame(() => {
-                        setDuration(video.getDuration());
-                    })
-                };
-                video.addEventListener(video.METADATA_EVENT, listener)
-                return () => {
-                    video.removeEventListener(video.METADATA_EVENT, listener)
-                }
-            }
-        }
-    }, [video]);
-    return duration;
+    const callback = useCallback((videoApi: PlayerApi) =>
+        videoApi.getDuration(), []);
+    return useVideoApi<number>(
+        [video?.DURATIONCHANGE_EVENT],
+        video,
+        0,
+        callback
+    )
 }
diff --git a/src/util/media/usePaused.ts b/src/util/media/usePaused.ts
index 019e98a..4f3e62f 100644
--- a/src/util/media/usePaused.ts
+++ b/src/util/media/usePaused.ts
@@ -1,22 +1,14 @@
-import {useEffect, useState} from "react";
 import {PlayerApi} from "../../routes/player/video/PlayerApi";
+import {useVideoApi} from "./useVideoApi";
+import {useCallback} from "react";
 
 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;
+    const callback = useCallback((videoApi: PlayerApi) =>
+        videoApi.isPaused(), []);
+    return useVideoApi<boolean>(
+        [video?.PLAY_EVENT, video?.PAUSE_EVENT],
+        video,
+        true,
+        callback
+    );
 }
diff --git a/src/util/media/usePlayerState.ts b/src/util/media/usePlayerState.ts
new file mode 100644
index 0000000..620fb56
--- /dev/null
+++ b/src/util/media/usePlayerState.ts
@@ -0,0 +1,30 @@
+import {PlayerApi} from "../../routes/player/video/PlayerApi";
+import {useVideoApi} from "./useVideoApi";
+
+interface PlayerState {
+    currentTime: number,
+    duration: number,
+    paused: boolean,
+}
+
+export const usePlayerState = (video: PlayerApi | null) => useVideoApi<PlayerState>(
+    [
+        video?.DURATIONCHANGE_EVENT,
+        video?.TIMECHANGE_EVENT,
+        video?.PLAY_EVENT,
+        video?.PAUSE_EVENT,
+    ],
+    video,
+    {
+        currentTime: 0,
+        duration: 0,
+        paused: true,
+    },
+    (videoApi: PlayerApi) => {
+        return {
+            currentTime: videoApi.getCurrentTime(),
+            duration: videoApi.getDuration(),
+            paused: videoApi.isPaused(),
+        }
+    }
+)
diff --git a/src/util/media/useVideoApi.ts b/src/util/media/useVideoApi.ts
new file mode 100644
index 0000000..b6ede31
--- /dev/null
+++ b/src/util/media/useVideoApi.ts
@@ -0,0 +1,41 @@
+import {useEffect, useState} from "react";
+import {PlayerApi} from "../../routes/player/video/PlayerApi";
+
+export function useVideoApi<T>(
+    events: (string|undefined|null)[],
+    video: PlayerApi | null,
+    initial: T,
+    mapper: (video: PlayerApi) => T
+): T {
+    const [result, setResult] = useState<T>(initial);
+    useEffect(() => {
+        if (video !== null) {
+            let requestFrame: number | null = null;
+            const listener = () => {
+                if (requestFrame !== null) {
+                    window.cancelAnimationFrame(requestFrame)
+                }
+                requestFrame = window.requestAnimationFrame(() => {
+                    setResult(mapper(video));
+                })
+            };
+            listener();
+            for (let event in events) {
+                if (event !== null && event !== undefined) {
+                    video.addEventListener(event, listener);
+                }
+            }
+            return () => {
+                if (requestFrame) {
+                    window.cancelAnimationFrame(requestFrame);
+                }
+                for (let event in events) {
+                    if (event !== null && event !== undefined) {
+                        video.removeEventListener(event, listener);
+                    }
+                }
+            }
+        }
+    }, [events, mapper, video]);
+    return result;
+}
diff --git a/src/util/subtitles/ass/SubtitlesOctopus.ts b/src/util/subtitles/ssa/SubtitlesOctopus.ts
similarity index 100%
rename from src/util/subtitles/ass/SubtitlesOctopus.ts
rename to src/util/subtitles/ssa/SubtitlesOctopus.ts
diff --git a/src/util/subtitles/ass/SubtitlesOctopusOptions.ts b/src/util/subtitles/ssa/SubtitlesOctopusOptions.ts
similarity index 100%
rename from src/util/subtitles/ass/SubtitlesOctopusOptions.ts
rename to src/util/subtitles/ssa/SubtitlesOctopusOptions.ts
diff --git a/src/util/subtitles/ass/browserHasAlphaBug.ts b/src/util/subtitles/ssa/browserHasAlphaBug.ts
similarity index 100%
rename from src/util/subtitles/ass/browserHasAlphaBug.ts
rename to src/util/subtitles/ssa/browserHasAlphaBug.ts
diff --git a/src/util/subtitles/ass/browserSupportsWebAssembly.ts b/src/util/subtitles/ssa/browserSupportsWebAssembly.ts
similarity index 100%
rename from src/util/subtitles/ass/browserSupportsWebAssembly.ts
rename to src/util/subtitles/ssa/browserSupportsWebAssembly.ts
-- 
GitLab