Kyoo/front/packages/ui/src/player/video.web.tsx
2024-05-12 17:30:16 +02:00

444 lines
13 KiB
TypeScript

/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type Audio, type Subtitle, getToken } from "@kyoo/models";
import { Menu, tooltip } from "@kyoo/primitives";
import Hls, { type Level, type LoadPolicy } from "hls.js";
import Jassub from "jassub";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
type ComponentProps,
type RefObject,
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
} from "react";
import { useTranslation } from "react-i18next";
import type { VideoProps } from "react-native-video";
import toVttBlob from "srt-webvtt";
import { useForceRerender, useYoshiki } from "yoshiki";
import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./state";
let hls: Hls | null = null;
function uuidv4(): string {
// @ts-ignore I have no clue how this works, thanks https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
);
}
const client_id = typeof window === "undefined" ? "ssr" : uuidv4();
const initHls = (): Hls => {
if (hls) hls.destroy();
const loadPolicy: LoadPolicy = {
default: {
maxTimeToFirstByteMs: Number.POSITIVE_INFINITY,
maxLoadTimeMs: 60_000,
timeoutRetry: {
maxNumRetry: 2,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 1,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
},
};
hls = new Hls({
xhrSetup: async (xhr) => {
const token = await getToken();
if (token) xhr.setRequestHeader("Authorization", token);
xhr.setRequestHeader("X-CLIENT-ID", client_id);
},
autoStartLoad: false,
startLevel: Number.POSITIVE_INFINITY,
abrEwmaDefaultEstimate: 35_000_000,
abrEwmaDefaultEstimateMax: 50_000_000,
// debug: true,
lowLatencyMode: false,
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: Number.POSITIVE_INFINITY,
maxLoadTimeMs: 60_000,
timeoutRetry: {
maxNumRetry: 5,
retryDelayMs: 100,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 5,
retryDelayMs: 0,
maxRetryDelayMs: 100,
},
},
},
keyLoadPolicy: loadPolicy,
certLoadPolicy: loadPolicy,
playlistLoadPolicy: loadPolicy,
manifestLoadPolicy: loadPolicy,
steeringManifestLoadPolicy: loadPolicy,
});
return hls;
};
const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function Video(
{
source,
paused,
muted,
volume,
onBuffer,
onLoad,
onProgress,
onError,
onEnd,
onPlaybackStateChanged,
onMediaUnsupported,
fonts,
},
forwaredRef,
) {
const ref = useRef<HTMLVideoElement>(null);
const oldHls = useRef<string | null>(null);
const { css } = useYoshiki();
const errorHandler = useRef<typeof onError>(onError);
errorHandler.current = onError;
useImperativeHandle(
forwaredRef,
() => ({
seek: (value: number) => {
if (ref.current) ref.current.currentTime = value;
},
}),
[],
);
useEffect(() => {
if (!ref.current || paused === ref.current.paused) return;
if (paused) ref.current?.pause();
else ref.current?.play().catch(() => {});
}, [paused]);
useEffect(() => {
if (!ref.current || !volume) return;
ref.current.volume = Math.max(0, Math.min(volume, 100)) / 100;
}, [volume]);
const subtitle = useAtomValue(subtitleAtom);
useSubtitle(ref, subtitle, fonts);
// biome-ignore lint/correctness/useExhaustiveDependencies: do not restart on startPosition change
useLayoutEffect(() => {
if (!ref?.current || !source.uri) return;
if (!hls || oldHls.current !== source.hls) {
// Reinit the hls player when we change track.
hls = initHls();
hls.loadSource(source.hls!);
oldHls.current = source.hls;
}
if (!source.uri.endsWith(".m3u8")) {
hls.detachMedia();
ref.current.src = source.uri;
} else {
hls.attachMedia(ref.current);
hls.startLoad(source.startPosition ? source.startPosition / 1000 : 0);
hls.on(Hls.Events.ERROR, (_, d) => {
if (!d.fatal || !hls?.media) return;
console.warn("Hls error", d);
errorHandler.current?.({
error: { errorString: d.reason ?? d.error?.message ?? "Unknown hls error" },
});
});
}
}, [source.uri, source.hls]);
useEffect(() => {
return () => {
console.log("hls cleanup");
if (hls) hls.destroy();
hls = null;
};
}, []);
const mode = useAtomValue(playModeAtom);
const audio = useAtomValue(audioAtom);
// biome-ignore lint/correctness/useExhaustiveDependencies: also change when the mode change
useEffect(() => {
if (!hls) return;
const update = () => {
if (!hls) return;
hls.audioTrack = audio?.index ?? 0;
};
update();
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, update);
return () => hls?.off(Hls.Events.AUDIO_TRACKS_UPDATED, update);
}, [audio, mode]);
const setPlay = useSetAtom(playAtom);
useEffect(() => {
if (!ref.current) return;
// Set play state to the player's value (if autoplay is denied)
setPlay(!ref.current.paused);
}, [setPlay]);
const setProgress = useSetAtom(progressAtom);
return (
<video
ref={ref}
src={source.uri}
muted={muted}
autoPlay={!paused}
controls={false}
playsInline
onCanPlay={() => onBuffer?.call(null, { isBuffering: false })}
onWaiting={() => onBuffer?.call(null, { isBuffering: true })}
onDurationChange={() => {
if (!ref.current) return;
onLoad?.call(null, { duration: ref.current.duration } as any);
}}
onTimeUpdate={() => {
if (!ref.current) return;
onProgress?.call(null, {
currentTime: ref.current.currentTime,
playableDuration: ref.current.buffered.length
? ref.current.buffered.end(ref.current.buffered.length - 1)
: 0,
seekableDuration: 0,
});
}}
onError={() => {
if (ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
onMediaUnsupported?.call(undefined);
else {
onError?.call(null, {
error: { errorString: ref.current?.error?.message ?? "Unknown error" },
});
}
}}
onLoadedMetadata={() => {
if (source.startPosition) setProgress(source.startPosition / 1000);
}}
onPlay={() => onPlaybackStateChanged?.({ isPlaying: true })}
onPause={() => onPlaybackStateChanged?.({ isPlaying: false })}
onEnded={onEnd}
{...css({ width: "100%", height: "100%", objectFit: "contain" })}
/>
);
});
export default Video;
export const canPlay = (codec: string) => {
// most chrome based browser (and safari I think) supports matroska but reports they do not.
// for those browsers, only check the codecs and not the container.
if (navigator.userAgent.search("Firefox") === -1)
codec = codec.replace("video/x-matroska", "video/mp4");
const videos = document.getElementsByTagName("video");
const video = videos.item(0) ?? document.createElement("video");
return !!video.canPlayType(codec);
};
const useSubtitle = (
player: RefObject<HTMLVideoElement>,
value: Subtitle | null,
fonts?: string[],
) => {
const htmlTrack = useRef<HTMLTrackElement | null>();
const subOcto = useRef<Jassub | null>();
useEffect(() => {
if (!player.current) return;
const removeHtmlSubtitle = () => {
if (htmlTrack.current) htmlTrack.current.remove();
htmlTrack.current = null;
};
const removeOctoSub = () => {
if (subOcto.current) subOcto.current.destroy();
subOcto.current = null;
};
if (!value || !value.link) {
removeHtmlSubtitle();
removeOctoSub();
} else if (value.codec === "vtt" || value.codec === "subrip") {
removeOctoSub();
if (player.current.textTracks.length > 0) player.current.textTracks[0].mode = "hidden";
const addSubtitle = async () => {
const track: HTMLTrackElement = htmlTrack.current ?? document.createElement("track");
track.kind = "subtitles";
track.label = value.displayName;
if (value.language) track.srclang = value.language;
track.src = value.codec === "subrip" ? await toWebVtt(value.link!) : value.link!;
track.className = "subtitle_container";
track.default = true;
track.onload = () => {
if (player.current) player.current.textTracks[0].mode = "showing";
};
if (!htmlTrack.current) {
htmlTrack.current = track;
if (player.current) player.current.appendChild(track);
}
};
addSubtitle();
} else if (value.codec === "ass") {
removeHtmlSubtitle();
// Also recreate jassub when the player changes (this is not the most effective but
// since it creates a div/canvas, it needs to be recreated when the UI rerender)
// @ts-expect-error We are accessing the private _video field here.
if (!subOcto.current || subOcto.current._video !== player.current) {
removeOctoSub();
subOcto.current = new Jassub({
video: player.current,
workerUrl: "/_next/static/chunks/jassub-worker.js",
wasmUrl: "/_next/static/chunks/jassub-worker.wasm",
legacyWasmUrl: "/_next/static/chunks/jassub-worker.wasm.js",
// Disable offscreen renderer due to bugs on firefox and chrome android
// (see https://github.com/ThaUnknown/jassub/issues/31)
offscreenRender: false,
subUrl: value.link,
fonts: fonts,
});
} else {
subOcto.current.freeTrack();
subOcto.current.setTrackByUrl(value.link);
}
}
}, [player, value, fonts]);
useEffect(() => {
return () => {
if (subOcto.current) subOcto.current.destroy();
subOcto.current = null;
if (htmlTrack.current) htmlTrack.current.remove();
htmlTrack.current = null;
};
}, []);
};
const toWebVtt = async (srtUrl: string) => {
const token = await getToken();
const query = await fetch(srtUrl, {
headers: token
? {
Authorization: token,
}
: undefined,
});
const srt = await query.blob();
return await toVttBlob(srt);
};
export const AudiosMenu = ({
audios,
...props
}: ComponentProps<typeof Menu<{ disabled?: boolean }>> & { audios?: Audio[] }) => {
const { t } = useTranslation();
const rerender = useForceRerender();
const [_, setAudio] = useAtom(audioAtom);
// force rerender when mode changes
useAtomValue(playModeAtom);
useEffect(() => {
if (!hls) return;
hls.on(Hls.Events.AUDIO_TRACK_LOADED, rerender);
return () => hls?.off(Hls.Events.AUDIO_TRACK_LOADED, rerender);
});
if (!hls) return <Menu {...props} disabled {...tooltip(t("player.notInPristine"))} />;
if (hls.audioTracks.length < 2) return null;
return (
<Menu {...props}>
{hls.audioTracks.map((x, i) => (
<Menu.Item
key={i.toString()}
label={audios?.[i]?.displayName ?? x.name}
selected={hls!.audioTrack === i}
onSelect={() => setAudio(audios?.[i] ?? ({ index: i } as any))}
/>
))}
</Menu>
);
};
export const QualitiesMenu = (props: ComponentProps<typeof Menu>) => {
const { t } = useTranslation();
const [mode, setPlayMode] = useAtom(playModeAtom);
const rerender = useForceRerender();
// biome-ignore lint/correctness/useExhaustiveDependencies: Inculde hls in dependency array
useEffect(() => {
if (!hls) return;
// Also rerender when hls instance changes
rerender();
hls.on(Hls.Events.LEVEL_SWITCHED, rerender);
return () => hls?.off(Hls.Events.LEVEL_SWITCHED, rerender);
}, [hls]);
const levelName = (label: Level, auto?: boolean): string => {
const height = `${label.height}p`;
if (auto) return height;
return label.uri.includes("original") ? `${t("player.transmux")} (${height})` : height;
};
return (
<Menu {...props}>
<Menu.Item
label={t("player.direct")}
selected={hls === null || mode === PlayMode.Direct}
onSelect={() => setPlayMode(PlayMode.Direct)}
/>
<Menu.Item
label={
hls?.autoLevelEnabled && hls.currentLevel >= 0
? `${t("player.auto")} (${levelName(hls.levels[hls.currentLevel], true)})`
: t("player.auto")
}
selected={hls?.autoLevelEnabled && mode === PlayMode.Hls}
onSelect={() => {
setPlayMode(PlayMode.Hls);
if (hls) hls.currentLevel = -1;
}}
/>
{hls?.levels
.map((x, i) => (
<Menu.Item
key={i.toString()}
label={levelName(x)}
selected={mode === PlayMode.Hls && hls!.currentLevel === i && !hls?.autoLevelEnabled}
onSelect={() => {
setPlayMode(PlayMode.Hls);
hls!.currentLevel = i;
}}
/>
))
.reverse()}
</Menu>
);
};