Use react-native-video instead of expo-av and add subtitle support

This commit is contained in:
Zoe Roux 2022-12-30 16:40:51 +09:00
parent e741b5aa6d
commit 84b4c998a7
12 changed files with 192 additions and 186 deletions

View File

@ -0,0 +1,62 @@
/*
* 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/>.
*/
const IS_DEV = process.env.APP_VARIANT === "development";
const config = {
expo: {
name: "kyoo",
slug: "kyoo",
scheme: "kyoo",
version: "1.0.0",
orientation: "default",
icon: "./assets/icon.png",
entryPoint: "./index.ts",
userInterfaceStyle: "light",
splash: {
image: "./assets/splash.png",
resizeMode: "contain",
backgroundColor: "#ffffff",
},
updates: {
fallbackToCacheTimeout: 0,
},
assetBundlePatterns: ["**/*"],
ios: {
supportsTablet: true,
},
android: {
package: IS_DEV ? "moe.sdg.kyoo.dev" : "moe.sdg.kyoo",
adaptiveIcon: {
foregroundImage: "./assets/adaptive-icon.png",
backgroundColor: "#FFFFFF",
},
},
web: {
favicon: "./assets/favicon.png",
},
extra: {
eas: {
projectId: "55de6b52-c649-4a15-9a45-569ff5ed036c",
},
},
},
};
export default config;

View File

@ -1,49 +0,0 @@
{
"expo": {
"name": "kyoo",
"slug": "kyoo",
"scheme": "kyoo",
"version": "1.0.0",
"orientation": "default",
"icon": "./assets/icon.png",
"entryPoint": "./index.ts",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"updates": {
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true
},
"android": {
"package": "moe.sdg.kyoo",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#FFFFFF"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"extra": {
"eas": {
"projectId": "55de6b52-c649-4a15-9a45-569ff5ed036c"
}
},
"plugins": [
[
"expo-build-properties",
{
"android": {
"compileSdkVersion": 31
}
}
]
]
}
}

View File

@ -6,7 +6,9 @@
"dev": "expo start", "dev": "expo start",
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web" "web": "expo start --web",
"build": "eas build --profile production --platform android",
"build:dev": "eas build --profile development --platform android"
}, },
"dependencies": { "dependencies": {
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",

View File

@ -6,6 +6,8 @@
"web": "yarn workspace web dev", "web": "yarn workspace web dev",
"mobile": "yarn workspace mobile dev", "mobile": "yarn workspace mobile dev",
"build:web": "yarn workspace web build", "build:web": "yarn workspace web build",
"build:mobile": "yarn workspace mobile build",
"build:mobile:dev": "yarn workspace mobile build:dev",
"lint": "eslint ." "lint": "eslint ."
}, },
"workspaces": [ "workspaces": [

View File

@ -36,12 +36,12 @@ const queryFn = async <Data,>(
context: QueryFunctionContext, context: QueryFunctionContext,
): Promise<Data> => { ): Promise<Data> => {
const kyooUrl = const kyooUrl =
Platform.OS !== "web" (Platform.OS !== "web"
? process.env.PUBLIC_BACK_URL ? process.env.PUBLIC_BACK_URL
: typeof window === "undefined" : typeof window === "undefined"
? process.env.KYOO_URL ?? "http://localhost:5000" ? process.env.KYOO_URL ?? "http://localhost:5000"
: "/api"; // TODO remove the hardcoded fallback. This is just for testing purposes
console.log(process.env.PUBLIC_BACK_URL) : "/api") ?? "https://beta.sdg.moe";
if (!kyooUrl) console.error("Kyoo's url is not defined."); if (!kyooUrl) console.error("Kyoo's url is not defined.");
let resp; let resp;

View File

@ -21,10 +21,11 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { z } from "zod"; import { z } from "zod";
export const imageFn = (url: string) => { export const imageFn = (url: string) =>
console.log(process.env.PUBLIC_BACK_URL) // TODO remove the hardcodded fallback
return Platform.OS === "web" ? `/api/${url}` : process.env.PUBLIC_BACK_URL + url; Platform.OS === "web"
} ? `/api/${url}`
: (process.env.PUBLIC_BACK_URL ?? "https://beta.sdg.moe") + url;
export const ImagesP = z.object({ export const ImagesP = z.object({
/** /**

View File

@ -30,7 +30,7 @@ import {
ViewStyle, ViewStyle,
} from "react-native"; } from "react-native";
import { percent, useYoshiki } from "yoshiki/native"; import { percent, useYoshiki } from "yoshiki/native";
import { StyleList, YoshikiStyle } from "yoshiki/dist/type"; import { YoshikiStyle } from "yoshiki/dist/type";
import { Skeleton } from "./skeleton"; import { Skeleton } from "./skeleton";
import { LinearGradient, LinearGradientProps } from "expo-linear-gradient"; import { LinearGradient, LinearGradientProps } from "expo-linear-gradient";
import { alpha, ContrastArea } from "./themes"; import { alpha, ContrastArea } from "./themes";

View File

@ -142,7 +142,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
setProgress={setProgress} setProgress={setProgress}
subtleProgress={buffered} subtleProgress={buffered}
max={duration} max={duration}
markers={chapters?.map((x) => x.startTime * 1000)} markers={chapters?.map((x) => x.startTime)}
/> />
); );
}; };

View File

@ -132,6 +132,6 @@ const ProgressText = () => {
const toTimerString = (timer?: number, duration?: number) => { const toTimerString = (timer?: number, duration?: number) => {
if (timer === undefined) return "??:??"; if (timer === undefined) return "??:??";
if (!duration) duration = timer; if (!duration) duration = timer;
if (duration >= 3600_000) return new Date(timer).toISOString().substring(11, 19); if (duration >= 3600) return new Date(timer * 1000).toISOString().substring(11, 19);
return new Date(timer).toISOString().substring(14, 19); return new Date(timer * 1000).toISOString().substring(14, 19);
}; };

View File

@ -19,8 +19,9 @@
*/ */
import { Font, Track } from "@kyoo/models"; import { Font, Track } from "@kyoo/models";
import { IconButton, tooltip, Menu, ts, A } from "@kyoo/primitives"; import { IconButton, tooltip, Menu, ts } from "@kyoo/primitives";
import { useAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useEffect } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg"; import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
@ -30,7 +31,7 @@ import { Stylable, useYoshiki } from "yoshiki/native";
import { createParam } from "solito"; import { createParam } from "solito";
import { fullscreenAtom, subtitleAtom } from "../state"; import { fullscreenAtom, subtitleAtom } from "../state";
const { useParam } = createParam<{ subtitle?: (string) }>(); const { useParam } = createParam<{ subtitle?: string }>();
export const RightButtons = ({ export const RightButtons = ({
subtitles, subtitles,
@ -47,8 +48,17 @@ export const RightButtons = ({
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
const setSubAtom = useSetAtom(subtitleAtom);
const [selectedSubtitle, setSubtitle] = useParam("subtitle"); const [selectedSubtitle, setSubtitle] = useParam("subtitle");
useEffect(() => {
const sub =
subtitles?.find(
(x) => x.language === selectedSubtitle || x.id.toString() === selectedSubtitle,
) ?? null;
setSubAtom(sub);
}, [subtitles, selectedSubtitle, setSubAtom]);
const spacing = css({ marginHorizontal: ts(1) }); const spacing = css({ marginHorizontal: ts(1) });
return ( return (
@ -88,4 +98,3 @@ export const RightButtons = ({
</View> </View>
); );
}; };

View File

@ -187,7 +187,6 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
> >
<Video <Video
links={data?.link} links={data?.link}
videoStyle={{ width: percent(100), height: percent(100) }}
setError={setPlaybackError} setError={setPlaybackError}
{...css(StyleSheet.absoluteFillObject)} {...css(StyleSheet.absoluteFillObject)}
// onEnded={() => { // onEnded={() => {

View File

@ -71,6 +71,8 @@ export const [privateFullscreen, fullscreenAtom] = bakedAtom(
}, },
); );
export const subtitleAtom = atom<Track | null>(null);
let hls: Hls | null = null; let hls: Hls | null = null;
export const Video = ({ export const Video = ({
@ -78,8 +80,6 @@ export const Video = ({
setError, setError,
...props ...props
}: { links?: WatchItem["link"]; setError: (error: string | undefined) => void } & VideoProps) => { }: { links?: WatchItem["link"]; setError: (error: string | undefined) => void } & VideoProps) => {
// const [playMode, setPlayMode] = useAtom(playModeAtom);
const ref = useRef<NativeVideo | null>(null); const ref = useRef<NativeVideo | null>(null);
const isPlaying = useAtomValue(playAtom); const isPlaying = useAtomValue(playAtom);
const setLoad = useSetAtom(loadAtom); const setLoad = useSetAtom(loadAtom);
@ -92,9 +92,9 @@ export const Video = ({
const setPrivateProgress = useSetAtom(privateProgressAtom); const setPrivateProgress = useSetAtom(privateProgressAtom);
const setBuffered = useSetAtom(bufferedAtom); const setBuffered = useSetAtom(bufferedAtom);
const setDuration = useSetAtom(durationAtom); const setDuration = useSetAtom(durationAtom);
// useEffect(() => { useEffect(() => {
// ref.current?.setStatusAsync({ positionMillis: publicProgress }); ref.current?.seek(publicProgress);
// }, [publicProgress]); }, [publicProgress]);
const volume = useAtomValue(volumeAtom); const volume = useAtomValue(volumeAtom);
const isMuted = useAtomValue(mutedAtom); const isMuted = useAtomValue(mutedAtom);
@ -109,6 +109,8 @@ export const Video = ({
return () => document.removeEventListener("fullscreenchange", handler); return () => document.removeEventListener("fullscreenchange", handler);
}); });
const subtitle = useAtomValue(subtitleAtom);
// useEffect(() => { // useEffect(() => {
// setPlayMode(PlayMode.Direct); // setPlayMode(PlayMode.Direct);
// }, [links, setPlayMode]); // }, [links, setPlayMode]);
@ -134,139 +136,117 @@ export const Video = ({
// } // }
// }, [playMode, links, player]); // }, [playMode, links, player]);
// useEffect(() => { if (!links) return null;
// if (!player?.current?.duration) return;
// setDuration(player.current.duration);
// }, [player, setDuration]);
//
return ( return (
<NativeVideo <NativeVideo
ref={ref} ref={ref}
{...props} {...props}
source={{ uri: links?.direct }} source={{ uri: links.direct }}
paused={!isPlaying} paused={!isPlaying}
muted={isMuted} muted={isMuted}
volume={volume} volume={volume}
// resizeMode={ResizeMode.CONTAIN} resizeMode="contain"
// onPlaybackStatusUpdate={(status) => { onBuffer={({ isBuffering }) => setLoad(isBuffering)}
// if (!status.isLoaded) { onError={(status) => setError(status.error.errorString)}
// setLoad(true); onProgress={(progress) => {
// if (status.error) setError(status.error); setPrivateProgress(progress.currentTime);
// return; setBuffered(progress.playableDuration);
// } }}
onLoad={(info) => {
// setLoad(status.isPlaying !== status.shouldPlay); console.log(info);
// setPrivateProgress(status.positionMillis); setDuration(info.duration);
// setBuffered(status.playableDurationMillis ?? 0); }}
// setDuration(status.durationMillis); selectedTextTrack={
// }} subtitle
? {
// ref: player, type: "index",
// shouldPlay: isPlaying, value: subtitle.trackIndex,
// onDoubleClick: () => { }
// setFullscreen(!document.fullscreenElement); : { type: "disabled" }
// }, }
// onPlay: () => setPlay(true), // TODO: textTracks: external subtitles
// onPause: () => setPlay(false),
// onWaiting: () => setLoad(true),
// onCanPlay: () => setLoad(false),
// onError: () => { // onError: () => {
// if (player?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) // if (player?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
// setPlayMode(PlayMode.Transmux); // setPlayMode(PlayMode.Transmux);
// }, // },
// onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
// onDurationChange: () => setDuration(player?.current?.duration ?? 0),
// onProgress: () =>
// setBuffered(
// player?.current?.buffered.length
// ? player.current.buffered.end(player.current.buffered.length - 1)
// : 0,
// ),
// onVolumeChange: () => {
// if (!player.current) return;
// setVolume(player.current.volume * 100);
// setMuted(player?.current.muted);
// },
useNativeControls={false}
/> />
); );
}; };
const htmlTrackAtom = atom<HTMLTrackElement | null>(null); // const htmlTrackAtom = atom<HTMLTrackElement | null>(null);
const suboctoAtom = atom<SubtitleOctopus | null>(null); // const suboctoAtom = atom<SubtitleOctopus | null>(null);
export const [_subtitleAtom, subtitleAtom] = bakedAtom< // export const [_subtitleAtom, subtitleAtom] = bakedAtom<
Track | null, // Track | null,
{ track: Track; fonts: Font[] } | null // { track: Track; fonts: Font[] } | null
>(null, (get, set, value, baked) => { // >(null, (get, set, value, baked) => {
const removeHtmlSubtitle = () => { // const removeHtmlSubtitle = () => {
const htmlTrack = get(htmlTrackAtom); // const htmlTrack = get(htmlTrackAtom);
if (htmlTrack) htmlTrack.remove(); // if (htmlTrack) htmlTrack.remove();
set(htmlTrackAtom, null); // set(htmlTrackAtom, null);
}; // };
const removeOctoSub = () => { // const removeOctoSub = () => {
const subocto = get(suboctoAtom); // const subocto = get(suboctoAtom);
if (subocto) { // if (subocto) {
subocto.freeTrack(); // subocto.freeTrack();
subocto.dispose(); // subocto.dispose();
} // }
set(suboctoAtom, null); // set(suboctoAtom, null);
}; // };
const player = get(playerAtom); // const player = get(playerAtom);
if (!player?.current) return; // if (!player?.current) return;
if (get(baked)?.id === value?.track.id) return; // if (get(baked)?.id === value?.track.id) return;
set(baked, value?.track ?? null); // set(baked, value?.track ?? null);
if (!value) { // if (!value) {
removeHtmlSubtitle(); // removeHtmlSubtitle();
removeOctoSub(); // removeOctoSub();
} else if (value.track.codec === "vtt" || value.track.codec === "subrip") { // } else if (value.track.codec === "vtt" || value.track.codec === "subrip") {
removeOctoSub(); // removeOctoSub();
if (player.current.textTracks.length > 0) player.current.textTracks[0].mode = "hidden"; // if (player.current.textTracks.length > 0) player.current.textTracks[0].mode = "hidden";
const track: HTMLTrackElement = get(htmlTrackAtom) ?? document.createElement("track"); // const track: HTMLTrackElement = get(htmlTrackAtom) ?? document.createElement("track");
track.kind = "subtitles"; // track.kind = "subtitles";
track.label = value.track.displayName; // track.label = value.track.displayName;
if (value.track.language) track.srclang = value.track.language; // if (value.track.language) track.srclang = value.track.language;
track.src = value.track.link! + ".vtt"; // track.src = value.track.link! + ".vtt";
track.className = "subtitle_container"; // track.className = "subtitle_container";
track.default = true; // track.default = true;
track.onload = () => { // track.onload = () => {
if (player.current) player.current.textTracks[0].mode = "showing"; // if (player.current) player.current.textTracks[0].mode = "showing";
}; // };
if (!get(htmlTrackAtom)) player.current.appendChild(track); // if (!get(htmlTrackAtom)) player.current.appendChild(track);
set(htmlTrackAtom, track); // set(htmlTrackAtom, track);
} else if (value.track.codec === "ass") { // } else if (value.track.codec === "ass") {
removeHtmlSubtitle(); // removeHtmlSubtitle();
removeOctoSub(); // removeOctoSub();
set( // set(
suboctoAtom, // suboctoAtom,
new SubtitleOctopus({ // new SubtitleOctopus({
video: player.current, // video: player.current,
subUrl: value.track.link!, // subUrl: value.track.link!,
workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js", // workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js",
legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js", // legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js",
fonts: value.fonts?.map((x) => x.link), // fonts: value.fonts?.map((x) => x.link),
renderMode: "wasm-blend", // renderMode: "wasm-blend",
}), // }),
); // );
} // }
}); // });
const { useParam } = createParam<{ subtitle: string }>(); // const { useParam } = createParam<{ subtitle: string }>();
export const useSubtitleController = ( // export const useSubtitleController = (
player: RefObject<HTMLVideoElement>, // player: RefObject<HTMLVideoElement>,
subtitles?: Track[], // subtitles?: Track[],
fonts?: Font[], // fonts?: Font[],
) => { // ) => {
const [subtitle] = useParam("subtitle"); // const [subtitle] = useParam("subtitle");
const selectSubtitle = useSetAtom(subtitleAtom); // const selectSubtitle = useSetAtom(subtitleAtom);
const newSub = subtitles?.find((x) => x.language === subtitle); // const newSub = subtitles?.find((x) => x.language === subtitle);
useEffect(() => { // useEffect(() => {
if (newSub === undefined) return; // if (newSub === undefined) return;
selectSubtitle({ track: newSub, fonts: fonts ?? [] }); // selectSubtitle({ track: newSub, fonts: fonts ?? [] });
}, [player.current?.src, newSub, fonts, selectSubtitle]); // }, [player.current?.src, newSub, fonts, selectSubtitle]);
}; // };