mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-11-20 13:33:18 -05:00
264 lines
8.3 KiB
TypeScript
264 lines
8.3 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 Episode, type Subtitle, getLocalSetting, useAccount } from "@kyoo/models";
|
|
import { useSnackbar } from "@kyoo/primitives";
|
|
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
import { useAtomCallback } from "jotai/utils";
|
|
import {
|
|
type ElementRef,
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Platform } from "react-native";
|
|
import NativeVideo, { canPlay, type VideoProps } from "./video";
|
|
|
|
export const playAtom = atom(true);
|
|
export const loadAtom = atom(false);
|
|
|
|
export enum PlayMode {
|
|
Direct,
|
|
Hls,
|
|
}
|
|
export const playModeAtom = atom<PlayMode>(
|
|
getLocalSetting("playmode", "direct") !== "auto" ? PlayMode.Direct : PlayMode.Hls,
|
|
);
|
|
|
|
export const bufferedAtom = atom(0);
|
|
export const durationAtom = atom<number | undefined>(undefined);
|
|
|
|
export const progressAtom = atom(
|
|
(get) => get(privateProgressAtom),
|
|
(get, set, update: number | ((value: number) => number)) => {
|
|
const run = (value: number) => {
|
|
set(privateProgressAtom, value);
|
|
set(publicProgressAtom, value);
|
|
};
|
|
if (typeof update === "function") run(update(get(privateProgressAtom)));
|
|
else run(update);
|
|
},
|
|
);
|
|
const privateProgressAtom = atom(0);
|
|
const publicProgressAtom = atom(0);
|
|
|
|
export const volumeAtom = atom(100);
|
|
export const mutedAtom = atom(false);
|
|
|
|
export const fullscreenAtom = atom(
|
|
(get) => get(privateFullscreen),
|
|
(get, set, update: boolean | ((value: boolean) => boolean)) => {
|
|
const run = async (value: boolean) => {
|
|
try {
|
|
if (value) {
|
|
await document.body.requestFullscreen({
|
|
navigationUI: "hide",
|
|
});
|
|
set(privateFullscreen, true);
|
|
// @ts-expect-error Firefox does not support this so ts complains
|
|
await screen.orientation.lock("landscape");
|
|
} else {
|
|
if (document.fullscreenElement) await document.exitFullscreen();
|
|
set(privateFullscreen, false);
|
|
screen.orientation.unlock();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
if (typeof update === "function") run(update(get(privateFullscreen)));
|
|
else run(update);
|
|
},
|
|
);
|
|
const privateFullscreen = atom(false);
|
|
|
|
export const subtitleAtom = atom<Subtitle | null>(null);
|
|
export const audioAtom = atom<Audio>({ index: 0 } as Audio);
|
|
|
|
export const Video = memo(function Video({
|
|
links,
|
|
subtitles,
|
|
audios,
|
|
codec,
|
|
setError,
|
|
fonts,
|
|
startTime: startTimeP,
|
|
metadata,
|
|
...props
|
|
}: {
|
|
links?: Episode["links"];
|
|
subtitles?: Subtitle[];
|
|
audios?: Audio[];
|
|
codec?: string;
|
|
setError: (error: string | undefined) => void;
|
|
fonts?: string[];
|
|
startTime?: number | null;
|
|
metadata: {
|
|
title?: string;
|
|
description?: string;
|
|
imageUri?: string;
|
|
previous?: string;
|
|
next?: string;
|
|
};
|
|
} & Partial<VideoProps>) {
|
|
const ref = useRef<ElementRef<typeof NativeVideo> | null>(null);
|
|
const [isPlaying, setPlay] = useAtom(playAtom);
|
|
const setLoad = useSetAtom(loadAtom);
|
|
const [source, setSource] = useState<string | null>(null);
|
|
const [mode, setPlayMode] = useAtom(playModeAtom);
|
|
|
|
const startTime = useRef(startTimeP);
|
|
useLayoutEffect(() => {
|
|
startTime.current = startTimeP;
|
|
}, [startTimeP]);
|
|
|
|
const publicProgress = useAtomValue(publicProgressAtom);
|
|
const setPrivateProgress = useSetAtom(privateProgressAtom);
|
|
const setPublicProgress = useSetAtom(publicProgressAtom);
|
|
const setBuffered = useSetAtom(bufferedAtom);
|
|
useEffect(() => {
|
|
ref.current?.seek(publicProgress);
|
|
}, [publicProgress]);
|
|
|
|
const getProgress = useAtomCallback(useCallback((get) => get(progressAtom), []));
|
|
useEffect(() => {
|
|
// Reset the state when a new video is loaded.
|
|
|
|
let newMode = getLocalSetting("playmode", "direct") !== "auto" ? PlayMode.Direct : PlayMode.Hls;
|
|
// Only allow direct play if the device supports it
|
|
if (newMode === PlayMode.Direct && codec && !canPlay(codec)) {
|
|
console.log(`Browser can't natively play ${codec}, switching to hls stream.`);
|
|
newMode = PlayMode.Hls;
|
|
}
|
|
setPlayMode(newMode);
|
|
|
|
setSource((newMode === PlayMode.Direct ? links?.direct : links?.hls) ?? null);
|
|
setLoad(true);
|
|
setPrivateProgress(startTime.current ?? 0);
|
|
setPublicProgress(startTime.current ?? 0);
|
|
setPlay(true);
|
|
}, [links, codec, setLoad, setPrivateProgress, setPublicProgress, setPlay, setPlayMode]);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: do not change source when links change, this is done above
|
|
useEffect(() => {
|
|
setSource((mode === PlayMode.Direct ? links?.direct : links?.hls) ?? null);
|
|
// keep current time when changing between direct and hls.
|
|
startTime.current = getProgress();
|
|
setPlay(true);
|
|
}, [mode, getProgress, setPlay]);
|
|
|
|
const account = useAccount();
|
|
const defaultSubLanguage = account?.settings.subtitleLanguage;
|
|
const setSubtitle = useSetAtom(subtitleAtom);
|
|
|
|
// When the video change, try to persist the subtitle language.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
|
|
useEffect(() => {
|
|
if (!subtitles) return;
|
|
setSubtitle((subtitle) => {
|
|
const subRet = subtitle ? subtitles.find((x) => x.language === subtitle.language) : null;
|
|
if (subRet) return subRet;
|
|
if (!defaultSubLanguage) return null;
|
|
if (defaultSubLanguage === "default") return subtitles.find((x) => x.isDefault) ?? null;
|
|
return subtitles.find((x) => x.language === defaultSubLanguage) ?? null;
|
|
});
|
|
}, [subtitles, setSubtitle, defaultSubLanguage, ref.current]);
|
|
|
|
const defaultAudioLanguage = account?.settings.audioLanguage ?? "default";
|
|
const setAudio = useSetAtom(audioAtom);
|
|
// When the video change, try to persist the subtitle language.
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Also include the player ref, it can be initalised after the subtitles.
|
|
useEffect(() => {
|
|
if (!audios) return;
|
|
setAudio((audio) => {
|
|
if (audio) {
|
|
const ret = audios.find((x) => x.language === audio.language);
|
|
if (ret) return ret;
|
|
}
|
|
if (defaultAudioLanguage !== "default") {
|
|
const ret = audios.find((x) => x.language === defaultAudioLanguage);
|
|
if (ret) return ret;
|
|
}
|
|
return audios.find((x) => x.isDefault) ?? audios[0];
|
|
});
|
|
}, [audios, setAudio, defaultAudioLanguage, ref.current]);
|
|
|
|
const volume = useAtomValue(volumeAtom);
|
|
const isMuted = useAtomValue(mutedAtom);
|
|
|
|
const setFullscreen = useSetAtom(privateFullscreen);
|
|
useEffect(() => {
|
|
if (Platform.OS !== "web") return;
|
|
const handler = () => {
|
|
setFullscreen(document.fullscreenElement != null);
|
|
};
|
|
document.addEventListener("fullscreenchange", handler);
|
|
return () => document.removeEventListener("fullscreenchange", handler);
|
|
});
|
|
|
|
const createSnackbar = useSnackbar();
|
|
const { t } = useTranslation();
|
|
|
|
if (!source || !links) return null;
|
|
return (
|
|
<NativeVideo
|
|
ref={ref}
|
|
{...props}
|
|
source={{
|
|
uri: source,
|
|
startPosition: startTime.current ? startTime.current * 1000 : undefined,
|
|
metadata: metadata,
|
|
...links,
|
|
}}
|
|
showNotificationControls
|
|
playInBackground
|
|
playWhenInactive
|
|
paused={!isPlaying}
|
|
muted={isMuted}
|
|
volume={volume}
|
|
resizeMode="contain"
|
|
onBuffer={({ isBuffering }) => setLoad(isBuffering)}
|
|
onError={(status) => {
|
|
console.error(status);
|
|
setError(status.error.errorString);
|
|
}}
|
|
onProgress={(progress) => {
|
|
setPrivateProgress(progress.currentTime);
|
|
setBuffered(progress.playableDuration);
|
|
}}
|
|
onPlaybackStateChanged={(state) => setPlay(state.isPlaying)}
|
|
fonts={fonts}
|
|
subtitles={subtitles}
|
|
onMediaUnsupported={() => {
|
|
createSnackbar({
|
|
key: "unsuported",
|
|
label: t("player.unsupportedError"),
|
|
duration: 3,
|
|
});
|
|
if (mode === PlayMode.Direct) setPlayMode(PlayMode.Hls);
|
|
}}
|
|
/>
|
|
);
|
|
});
|