Add libass support

This commit is contained in:
2025-10-21 17:25:36 +02:00
parent 3590963206
commit 23832929e9
14 changed files with 130 additions and 736 deletions

View File

@@ -6,3 +6,4 @@
!/app.config.ts
!/src
!/public
!/scripts

View File

@@ -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 . .

View File

@@ -2,6 +2,7 @@ FROM oven/bun
WORKDIR /app
COPY package.json bun.lock .
COPY scripts scripts
RUN bun install --frozen-lockfile
COPY . .

View File

@@ -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=="],

View File

@@ -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
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View 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);
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}}
/>
);
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1,3 @@
import type { VideoPlayer } from "react-native-video";
export const enhanceSubtitles = (player: VideoPlayer) => player;

View 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;
};

View File

@@ -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",