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",
"android": "expo start --android",
"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": {
"@gorhom/portal": "^1.0.14",

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ import {
ViewStyle,
} from "react-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 { LinearGradient, LinearGradientProps } from "expo-linear-gradient";
import { alpha, ContrastArea } from "./themes";

View File

@ -142,7 +142,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
setProgress={setProgress}
subtleProgress={buffered}
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) => {
if (timer === undefined) return "??:??";
if (!duration) duration = timer;
if (duration >= 3600_000) return new Date(timer).toISOString().substring(11, 19);
return new Date(timer).toISOString().substring(14, 19);
if (duration >= 3600) return new Date(timer * 1000).toISOString().substring(11, 19);
return new Date(timer * 1000).toISOString().substring(14, 19);
};

View File

@ -19,8 +19,9 @@
*/
import { Font, Track } from "@kyoo/models";
import { IconButton, tooltip, Menu, ts, A } from "@kyoo/primitives";
import { useAtom } from "jotai";
import { IconButton, tooltip, Menu, ts } from "@kyoo/primitives";
import { useAtom, useSetAtom } from "jotai";
import { useEffect } from "react";
import { Platform, View } from "react-native";
import { useTranslation } from "react-i18next";
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 { fullscreenAtom, subtitleAtom } from "../state";
const { useParam } = createParam<{ subtitle?: (string) }>();
const { useParam } = createParam<{ subtitle?: string }>();
export const RightButtons = ({
subtitles,
@ -47,8 +48,17 @@ export const RightButtons = ({
const { css } = useYoshiki();
const { t } = useTranslation();
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
const setSubAtom = useSetAtom(subtitleAtom);
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) });
return (
@ -88,4 +98,3 @@ export const RightButtons = ({
</View>
);
};

View File

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

View File

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