mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-12-06 06:36:25 +00:00
Add libass support
This commit is contained in:
@@ -6,3 +6,4 @@
|
||||
!/app.config.ts
|
||||
!/src
|
||||
!/public
|
||||
!/scripts
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
FROM oven/bun AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock .
|
||||
RUN bun install --production
|
||||
COPY package.json bun.lock scripts .
|
||||
RUN bun install --production --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ FROM oven/bun
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock .
|
||||
COPY scripts scripts
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-updates": "~29.0.11",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jassub": "^1.8.6",
|
||||
"langmap": "^0.0.16",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -51,6 +52,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.6",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-dom": "~19.1.7",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
@@ -519,6 +521,8 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
|
||||
|
||||
"@types/inline-style-prefixer": ["@types/inline-style-prefixer@5.0.3", "", {}, "sha512-GOiSoBwH2U8LmbCnOLU6ZRPtm+qycO9sNXCvP+ahG0abpHrYTd1rm6ZPX4qYTFf1mTB6tqTQ9fYaJPcQWGFMSQ=="],
|
||||
@@ -645,6 +649,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="],
|
||||
@@ -965,6 +971,8 @@
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jassub": ["jassub@1.8.6", "", { "dependencies": { "rvfc-polyfill": "^1.0.7" } }, "sha512-56ZTtjM7LfdKsi7boUN/seNOQSOclLuDWEXxnHO55xNakj95SlBrv36hLyNDw0NmoOtLVeqzbpBU1VxT+ubFpg=="],
|
||||
|
||||
"jest-environment-node": ["jest-environment-node@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw=="],
|
||||
|
||||
"jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="],
|
||||
@@ -1315,6 +1323,8 @@
|
||||
|
||||
"rtl-detect": ["rtl-detect@1.1.2", "", {}, "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ=="],
|
||||
|
||||
"rvfc-polyfill": ["rvfc-polyfill@1.0.7", "", {}, "sha512-seBl7J1J3/k0LuzW2T9fG6JIOpni5AbU+/87LA+zTYKgTVhsfShmS8K/yOo1eeEjGJHnAdkVAUUM+PEjN9Mpkw=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
||||
@@ -1621,6 +1631,8 @@
|
||||
|
||||
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
|
||||
|
||||
"caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="],
|
||||
|
||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
@@ -1831,6 +1843,8 @@
|
||||
|
||||
"@types/graceful-fs/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
"chrome-launcher/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
"chromium-edge-launcher/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"postinstall": "bun ./scripts/postinstall.ts",
|
||||
"dev": "expo start",
|
||||
"apk": "eas build --profile preview --platform android --non-interactive --json",
|
||||
"apk:dev": "eas build --profile development --platform android --non-interactive",
|
||||
@@ -36,6 +37,7 @@
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-updates": "~29.0.11",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jassub": "^1.8.6",
|
||||
"langmap": "^0.0.16",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
@@ -60,6 +62,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.6",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-dom": "~19.1.7",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
|
||||
2
front/public/jassub/.gitignore
vendored
Normal file
2
front/public/jassub/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
12
front/scripts/postinstall.ts
Normal file
12
front/scripts/postinstall.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { readdir , mkdir } from 'node:fs/promises';
|
||||
|
||||
const srcDir = new URL("../node_modules/jassub/dist/", import.meta.url);
|
||||
const destDir = new URL("../public/jassub/", import.meta.url);
|
||||
|
||||
await mkdir(destDir, { recursive: true });
|
||||
|
||||
const files = await readdir(srcDir);
|
||||
for (const file of files) {
|
||||
const src = await Bun.file(new URL(file, srcDir)).arrayBuffer();
|
||||
await Bun.write(new URL(file, destDir), src);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useFetch } from "~/query";
|
||||
import { useDisplayName, useSubtitleName } from "~/track-utils";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { Player } from "..";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
type MenuProps = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
|
||||
|
||||
@@ -36,17 +37,6 @@ export const SubtitleMenu = ({
|
||||
.getAvailableTextTracks()
|
||||
.findIndex((x) => x.selected);
|
||||
|
||||
const select = (track: Subtitle | null, idx: number) => {
|
||||
if (!track) {
|
||||
player.selectTextTrack(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: filter by codec here
|
||||
const sub = player.getAvailableTextTracks()[idx];
|
||||
player.selectTextTrack(sub);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
@@ -57,14 +47,16 @@ export const SubtitleMenu = ({
|
||||
<Menu.Item
|
||||
label={t("player.subtitle-none")}
|
||||
selected={selectedIdx === -1}
|
||||
onSelect={() => select(null, -1)}
|
||||
onSelect={() => player.selectTextTrack(null)}
|
||||
/>
|
||||
{data?.subtitles.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={x.index ?? x.link}
|
||||
label={getDisplayName(x)}
|
||||
selected={i === selectedIdx}
|
||||
onSelect={() => select(x, i)}
|
||||
onSelect={() =>
|
||||
player.selectTextTrack(player.getAvailableTextTracks()[i])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Back } from "./controls/back";
|
||||
import { toggleFullscreen } from "./controls/misc";
|
||||
import { PlayModeContext } from "./controls/tracks-menu";
|
||||
import { useKeyboard } from "./keyboard";
|
||||
import { enhanceSubtitles } from "./subtitles";
|
||||
|
||||
const clientId = uuidv4();
|
||||
|
||||
@@ -81,6 +82,7 @@ export const Player = () => {
|
||||
p.playWhenInactive = true;
|
||||
p.playInBackground = true;
|
||||
p.showNotificationControls = true;
|
||||
enhanceSubtitles(p);
|
||||
const seek = start ?? data?.progress.time;
|
||||
// TODO: fix console.error bellow
|
||||
if (seek) p.seekTo(seek);
|
||||
@@ -89,6 +91,11 @@ export const Player = () => {
|
||||
},
|
||||
);
|
||||
|
||||
// we'll also want to replace source here once https://github.com/TheWidlarzGroup/react-native-video/issues/4722 is ready
|
||||
useEffect(() => {
|
||||
player.__ass.fonts = info?.fonts ?? [];
|
||||
}, [player, info?.fonts]);
|
||||
|
||||
const router = useRouter();
|
||||
const playPrev = useCallback(() => {
|
||||
if (!data?.previous) return false;
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
* 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, getDefaultStore, 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 VideoMetadata, type VideoProps } from "../videoideo";
|
||||
|
||||
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: VideoMetadata & { next?: string; previous?: 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 && x.isForced === subtitle.isForced,
|
||||
)
|
||||
: 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
|
||||
disableDisconnectError
|
||||
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) => {
|
||||
if (state.isSeeking || getDefaultStore().get(loadAtom)) return;
|
||||
setPlay(state.isPlaying);
|
||||
}}
|
||||
fonts={fonts}
|
||||
subtitles={subtitles}
|
||||
onMediaUnsupported={() => {
|
||||
createSnackbar({
|
||||
key: "unsuported",
|
||||
label: t("player.unsupportedError"),
|
||||
duration: 3,
|
||||
});
|
||||
if (mode === PlayMode.Direct) setPlayMode(PlayMode.Hls);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
/*
|
||||
* 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 { useDisplayName } from "../../../../packages/ui/src/utils";
|
||||
import { MediaSessionManager } from "./old/media-sessionn";
|
||||
import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./old/statee";
|
||||
|
||||
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 (
|
||||
<>
|
||||
<MediaSessionManager {...source.metadata} />
|
||||
<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, isSeeking: false })}
|
||||
onPause={() => onPlaybackStateChanged?.({ isPlaying: false, isSeeking: 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>();
|
||||
const mode = useAtom(playModeAtom);
|
||||
|
||||
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.title ?? value.language ?? "Subtitle";
|
||||
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);
|
||||
}
|
||||
}
|
||||
// also include mode because srt get's disabled when the mode change (no idea why)
|
||||
mode;
|
||||
}, [player.current, value, fonts, mode]);
|
||||
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);
|
||||
const getDisplayName = useDisplayName();
|
||||
// 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 ? getDisplayName(audios[i]) : 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>
|
||||
);
|
||||
};
|
||||
3
front/src/ui/player/subtitles.ts
Normal file
3
front/src/ui/player/subtitles.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
|
||||
export const enhanceSubtitles = (player: VideoPlayer) => player;
|
||||
62
front/src/ui/player/subtitles.web.ts
Normal file
62
front/src/ui/player/subtitles.web.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import Jassub from "jassub";
|
||||
import type { VideoPlayer } from "react-native-video";
|
||||
|
||||
declare module "react-native-video" {
|
||||
interface VideoPlayer {
|
||||
__getNativeRef(): HTMLVideoElement;
|
||||
__ass: {
|
||||
currentId?: string;
|
||||
jassub?: Jassub;
|
||||
fonts: string[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const enhanceSubtitles = (player: VideoPlayer) => {
|
||||
player.__ass = { fonts: [] };
|
||||
|
||||
const select = player.selectTextTrack.bind(player);
|
||||
player.selectTextTrack = (track) => {
|
||||
player.__ass.currentId = undefined;
|
||||
|
||||
// on the web, track.id is the url of the subtitle.
|
||||
if (!track || !track.id.endsWith(".ass")) {
|
||||
player.__ass.jassub?.destroy();
|
||||
player.__ass.jassub = undefined;
|
||||
select(track);
|
||||
return;
|
||||
}
|
||||
|
||||
// since we'll use a custom renderer for ass, disable the existing sub
|
||||
select(null);
|
||||
player.__ass.currentId = track.id;
|
||||
if (!player.__ass.jassub) {
|
||||
player.__ass.jassub = new Jassub({
|
||||
video: player.__getNativeRef(),
|
||||
workerUrl: "/jassub/jassub-worker.js",
|
||||
wasmUrl: "/jassub/jassub-worker.wasm",
|
||||
legacyWasmUrl: "/jassub/jassub-worker.wasm.js",
|
||||
modernWasmUrl: "/jassub/jassub-worker-modern.wasm",
|
||||
// Disable offscreen renderer due to bugs on firefox and chrome android
|
||||
// (see https://github.com/ThaUnknown/jassub/issues/31)
|
||||
// offscreenRender: false,
|
||||
subUrl: track.id,
|
||||
fonts: player.__ass.fonts,
|
||||
});
|
||||
} else {
|
||||
player.__ass.jassub.freeTrack();
|
||||
player.__ass.jassub.setTrackByUrl(track.id);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailable = player.getAvailableTextTracks.bind(player);
|
||||
player.getAvailableTextTracks = () => {
|
||||
const ret = getAvailable();
|
||||
if (player.__ass.currentId) {
|
||||
const current = ret.find((x) => x.id === player.__ass.currentId);
|
||||
if (current) current.selected = true;
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
return player;
|
||||
};
|
||||
@@ -2,7 +2,9 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"rootDir": ".",
|
||||
@@ -14,13 +16,25 @@
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node", "react"],
|
||||
"lib": ["dom", "esnext"]
|
||||
"types": [
|
||||
"node",
|
||||
"react"
|
||||
],
|
||||
"lib": [
|
||||
"dom",
|
||||
"esnext"
|
||||
]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".expo",
|
||||
"scripts",
|
||||
"**/test",
|
||||
"**/dist",
|
||||
"**/types",
|
||||
|
||||
Reference in New Issue
Block a user