Add transcode quality on the front

This commit is contained in:
Zoe Roux 2023-04-27 17:54:49 +09:00
parent 3778b2148c
commit 9a125e0359
No known key found for this signature in database
10 changed files with 129 additions and 67 deletions

View File

@ -141,11 +141,23 @@ namespace Kyoo.Abstractions.Models
/// </summary> /// </summary>
public ICollection<Chapter> Chapters { get; set; } public ICollection<Chapter> Chapters { get; set; }
string _Type => IsMovie ? "movie" : "episode";
/// <inheritdoc/> /// <inheritdoc/>
public object Link => new public object Link => new[]
{ {
Direct = $"/video/direct/{Slug}", new { Name = "Pristine", Link = $"/video/{_Type}/{Slug}/direct", Type = "direct" },
Transmux = $"/video/transmux/{Slug}/master.m3u8", new { Name = "Original", Link = $"/video/{_Type}/{Slug}/original/index.m3u8", Type = "transmux" },
new { Name = "Auto", Link = $"/video/{_Type}/{Slug}/auto/index.m3u8", Type = "transcode-auto" },
new { Name = "8K", Link = $"/video/{_Type}/{Slug}/8k/index.m3u8", Type = "transcode", },
new { Name = "4K", Link = $"/video/{_Type}/{Slug}/4k/index.m3u8", Type = "transcode" },
new { Name = "1440p", Link = $"/video/{_Type}/{Slug}/1440p/index.m3u8", Type = "transcode" },
new { Name = "1080p", Link = $"/video/{_Type}/{Slug}/1080p/index.m3u8", Type = "transcode" },
new { Name = "720p", Link = $"/video/{_Type}/{Slug}/720p/index.m3u8", Type = "transcode" },
new { Name = "480p", Link = $"/video/{_Type}/{Slug}/480p/index.m3u8", Type = "transcode" },
new { Name = "360p", Link = $"/video/{_Type}/{Slug}/360p/index.m3u8", Type = "transcode" },
new { Name = "240p", Link = $"/video/{_Type}/{Slug}/240p/index.m3u8", Type = "transcode" },
}; };
/// <summary> /// <summary>

View File

@ -129,7 +129,8 @@ const WatchMovieP = z.preprocess(
*/ */
releaseDate: zdate().nullable(), releaseDate: zdate().nullable(),
/** /**
* The container of the video file of this episode. Common containers are mp4, mkv, avi and so on. * The container of the video file of this episode. Common containers are mp4, mkv, avi and so
* on.
*/ */
container: z.string(), container: z.string(),
/** /**
@ -155,10 +156,13 @@ const WatchMovieP = z.preprocess(
/** /**
* The links to the videos of this watch item. * The links to the videos of this watch item.
*/ */
link: z.object({ link: z.array(
direct: z.string().transform(imageFn), z.object({
transmux: z.string().transform(imageFn), name: z.string(),
}), link: z.string().transform(imageFn),
type: z.enum(["direct", "transmux", "transcode-auto", "transcode"])
}),
),
}), }),
); );
@ -185,7 +189,8 @@ const WatchEpisodeP = WatchMovieP.and(
*/ */
episodeNumber: z.number().nullable(), episodeNumber: z.number().nullable(),
/** /**
* The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. * The absolute number of this episode. It's an episode number that is not reset to 1 after a
* new season.
*/ */
absoluteNumber: z.number().nullable(), absoluteNumber: z.number().nullable(),
/** /**

View File

@ -33,7 +33,7 @@ import {
tooltip, tooltip,
ts, ts,
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { Chapter, Font, Track } from "@kyoo/models"; import { Chapter, Font, Track, WatchItem } from "@kyoo/models";
import { useAtomValue, useSetAtom, useAtom } from "jotai"; import { useAtomValue, useSetAtom, useAtom } from "jotai";
import { View, ViewProps } from "react-native"; import { View, ViewProps } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -51,6 +51,7 @@ export const Hover = ({
href, href,
poster, poster,
chapters, chapters,
qualities,
subtitles, subtitles,
fonts, fonts,
previousSlug, previousSlug,
@ -66,6 +67,7 @@ export const Hover = ({
href?: string; href?: string;
poster?: string | null; poster?: string | null;
chapters?: Chapter[]; chapters?: Chapter[];
qualities?: WatchItem["link"]
subtitles?: Track[]; subtitles?: Track[];
fonts?: Font[]; fonts?: Font[];
previousSlug?: string | null; previousSlug?: string | null;
@ -117,6 +119,7 @@ export const Hover = ({
<RightButtons <RightButtons
subtitles={subtitles} subtitles={subtitles}
fonts={fonts} fonts={fonts}
qualities={qualities}
onMenuOpen={onMenuOpen} onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose} onMenuClose={onMenuClose}
/> />

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Font, Track } from "@kyoo/models"; import { Font, Track, WatchItem } from "@kyoo/models";
import { IconButton, tooltip, Menu, ts } from "@kyoo/primitives"; import { IconButton, tooltip, Menu, ts } from "@kyoo/primitives";
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -27,21 +27,21 @@ 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";
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg"; import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg"; import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg";
import { Stylable, useYoshiki } from "yoshiki/native"; import { Stylable, useYoshiki } from "yoshiki/native";
import { createParam } from "solito"; import { fullscreenAtom, qualityAtom, subtitleAtom } from "../state";
import { fullscreenAtom, subtitleAtom } from "../state";
const { useParam } = createParam<{ subtitle?: string }>();
export const RightButtons = ({ export const RightButtons = ({
subtitles, subtitles,
fonts, fonts,
qualities,
onMenuOpen, onMenuOpen,
onMenuClose, onMenuClose,
...props ...props
}: { }: {
subtitles?: Track[]; subtitles?: Track[];
fonts?: Font[]; fonts?: Font[];
qualities?: WatchItem["link"]
onMenuOpen: () => void; onMenuOpen: () => void;
onMenuClose: () => void; onMenuClose: () => void;
} & Stylable) => { } & Stylable) => {
@ -49,6 +49,7 @@ export const RightButtons = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
const setSubAtom = useSetAtom(subtitleAtom); const setSubAtom = useSetAtom(subtitleAtom);
const [quality, setQuality] = useAtom(qualityAtom);
const [selectedSubtitle, setSubtitle] = useState<string | undefined>(undefined); const [selectedSubtitle, setSubtitle] = useState<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
@ -87,6 +88,23 @@ export const RightButtons = ({
))} ))}
</Menu> </Menu>
)} )}
<Menu
Trigger={IconButton}
icon={SettingsIcon}
onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose}
{...tooltip(t("player.quality"), true)}
{...spacing}
>
{qualities?.map((x) => (
<Menu.Item
key={x.link}
label={x.name}
selected={quality === x.name}
onSelect={() => setQuality(x.name)}
/>
))}
</Menu>
{Platform.OS === "web" && ( {Platform.OS === "web" && (
<IconButton <IconButton
icon={isFullscreen ? FullscreenExit : Fullscreen} icon={isFullscreen ? FullscreenExit : Fullscreen}

View File

@ -50,6 +50,7 @@ const mapData = (
showName: data.isMovie ? data.name! : data.showTitle, showName: data.isMovie ? data.name! : data.showTitle,
href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#", href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#",
poster: data.poster, poster: data.poster,
qualities: data.link,
subtitles: data.subtitles, subtitles: data.subtitles,
chapters: data.chapters, chapters: data.chapters,
fonts: data.fonts, fonts: data.fonts,

View File

@ -20,12 +20,14 @@
import { Track, WatchItem, Font } from "@kyoo/models"; import { Track, WatchItem, Font } from "@kyoo/models";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { memo, useEffect, useLayoutEffect, useRef } from "react"; import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
import NativeVideo, { VideoProperties as VideoProps } from "./video"; import NativeVideo, { VideoProperties as VideoProps } from "./video";
import { Platform } from "react-native"; import { Platform } from "react-native";
export const playAtom = atom(true); export const playAtom = atom(true);
export const loadAtom = atom(false); export const loadAtom = atom(false);
// TODO: Default to auto or pristine depending on the user settings.
export const qualityAtom = atom<string>("Pristine");
export const bufferedAtom = atom(0); export const bufferedAtom = atom(0);
export const durationAtom = atom<number | undefined>(undefined); export const durationAtom = atom<number | undefined>(undefined);
@ -63,8 +65,6 @@ const privateFullscreen = atom(false);
export const subtitleAtom = atom<Track | null>(null); export const subtitleAtom = atom<Track | null>(null);
const MemoVideo = memo(NativeVideo);
export const Video = memo(function _Video({ export const Video = memo(function _Video({
links, links,
setError, setError,
@ -78,6 +78,8 @@ export const Video = memo(function _Video({
const ref = useRef<NativeVideo | null>(null); const ref = useRef<NativeVideo | null>(null);
const [isPlaying, setPlay] = useAtom(playAtom); const [isPlaying, setPlay] = useAtom(playAtom);
const setLoad = useSetAtom(loadAtom); const setLoad = useSetAtom(loadAtom);
const [source, setSource] = useState<WatchItem["link"][0] | null>(null);
const [quality, setQuality] = useAtom(qualityAtom);
const publicProgress = useAtomValue(publicProgressAtom); const publicProgress = useAtomValue(publicProgressAtom);
const setPrivateProgress = useSetAtom(privateProgressAtom); const setPrivateProgress = useSetAtom(privateProgressAtom);
@ -89,10 +91,11 @@ export const Video = memo(function _Video({
useLayoutEffect(() => { useLayoutEffect(() => {
// Reset the state when a new video is loaded. // Reset the state when a new video is loaded.
setSource(links?.find(x => x.name == quality) ?? null)
setLoad(true); setLoad(true);
setPrivateProgress(0); setPrivateProgress(0);
setPlay(true); setPlay(true);
}, [links, setLoad, setPrivateProgress, setPlay]); }, [quality, links, setLoad, setPrivateProgress, setPlay]);
const volume = useAtomValue(volumeAtom); const volume = useAtomValue(volumeAtom);
const isMuted = useAtomValue(mutedAtom); const isMuted = useAtomValue(mutedAtom);
@ -109,13 +112,12 @@ export const Video = memo(function _Video({
const subtitle = useAtomValue(subtitleAtom); const subtitle = useAtomValue(subtitleAtom);
if (!links) return null; if (!source) return null;
return ( return (
<MemoVideo <NativeVideo
ref={ref} ref={ref}
{...props} {...props}
// @ts-ignore Web only source={{ uri: source.link, ...source }}
source={{ uri: links.direct, transmux: links.transmux }}
paused={!isPlaying} paused={!isPlaying}
muted={isMuted} muted={isMuted}
volume={volume} volume={volume}
@ -139,6 +141,16 @@ export const Video = memo(function _Video({
: { type: "disabled" } : { type: "disabled" }
} }
fonts={fonts} fonts={fonts}
onMediaUnsupported={() => {
if (source.type === "direct")
setQuality(links?.find(x => x.type == "transmux")!.name!)
// TODO: Replace transcode with transcode-auto when supported.
if (source.type === "transmux")
setQuality(links?.find(x => x.type == "transcode")!.name!)
}}
// TODO: textTracks: external subtitles // TODO: textTracks: external subtitles
/> />
); );

View File

@ -18,7 +18,19 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
declare module "react-native-video" {
interface VideoProperties {
fonts?: Font[];
onPlayPause: (isPlaying: boolean) => void;
onMediaUnsupported?: () => void;
}
export type VideoProps = Omit<VideoProperties, "source"> & {
source: { uri: string } & WatchItem["link"][0];
};
}
export * from "react-native-video"; export * from "react-native-video";
import { Font, WatchItem } from "@kyoo/models";
import Video from "react-native-video"; import Video from "react-native-video";
export default Video; export default Video;

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Font, Track } from "@kyoo/models"; import { Font, getToken, Track } from "@kyoo/models";
import { import {
forwardRef, forwardRef,
RefObject, RefObject,
@ -28,30 +28,23 @@ import {
useRef, useRef,
} from "react"; } from "react";
import { VideoProps } from "react-native-video"; import { VideoProps } from "react-native-video";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useYoshiki } from "yoshiki"; import { useYoshiki } from "yoshiki";
import SubtitleOctopus from "libass-wasm"; import SubtitleOctopus from "libass-wasm";
import { playAtom, subtitleAtom } from "./state"; import { playAtom, subtitleAtom } from "./state";
import Hls from "hls.js"; import Hls from "hls.js";
declare module "react-native-video" {
interface VideoProperties {
fonts?: Font[];
onPlayPause: (isPlaying: boolean) => void;
}
export type VideoProps = Omit<VideoProperties, "source"> & {
source: { uri?: string; transmux?: string };
};
}
enum PlayMode {
Direct,
Transmux,
}
const playModeAtom = atom<PlayMode>(PlayMode.Direct);
let hls: Hls | null = null; 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),
);
}
let client_id = typeof window === "undefined" ? "ssr" : uuidv4();
const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function _Video( const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function _Video(
{ {
source, source,
@ -64,10 +57,12 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
onError, onError,
onEnd, onEnd,
onPlayPause, onPlayPause,
onMediaUnsupported,
fonts, fonts,
}, },
forwaredRef, forwaredRef,
) { ) {
const { uri, type } = source;
const ref = useRef<HTMLVideoElement>(null); const ref = useRef<HTMLVideoElement>(null);
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -94,28 +89,33 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
const subtitle = useAtomValue(subtitleAtom); const subtitle = useAtomValue(subtitleAtom);
useSubtitle(ref, subtitle, fonts); useSubtitle(ref, subtitle, fonts);
const [playMode, setPlayMode] = useAtom(playModeAtom);
useEffect(() => {
setPlayMode(PlayMode.Direct);
}, [source.uri, setPlayMode]);
useLayoutEffect(() => { useLayoutEffect(() => {
const src = playMode === PlayMode.Direct ? source?.uri : source?.transmux; (async () => {
if (!ref?.current || !uri || !type) return;
if (!ref?.current || !src) return; // TODO: Use hls.js even for safari or handle XHR requests with tokens,auto...
if (playMode == PlayMode.Direct || ref.current.canPlayType("application/vnd.apple.mpegurl")) { if (type === "direct" || ref.current.canPlayType("application/vnd.apple.mpegurl")) {
ref.current.src = src; ref.current.src = uri;
} else { } else {
if (hls === null) hls = new Hls(); if (hls === null) {
hls.loadSource(src); const token = await getToken();
hls.attachMedia(ref.current); hls = new Hls({
hls.on(Hls.Events.MANIFEST_LOADED, async () => { xhrSetup: (xhr) => {
try { if (token) xhr.setRequestHeader("Authorization", `Bearer: {token}`);
await ref.current?.play(); xhr.setRequestHeader("X-CLIENT-ID", client_id);
} catch {} },
}); });
} }
}, [playMode, source?.uri, source?.transmux]); hls.loadSource(uri);
hls.attachMedia(ref.current);
// TODO: Enable custom XHR for tokens
hls.on(Hls.Events.MANIFEST_LOADED, async () => {
try {
await ref.current?.play();
} catch {}
});
}
})();
}, [uri, type]);
const setPlay = useSetAtom(playAtom); const setPlay = useSetAtom(playAtom);
useEffect(() => { useEffect(() => {
@ -147,11 +147,8 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
}); });
}} }}
onError={() => { onError={() => {
if ( if (ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && onMediaUnsupported?.call(undefined);
playMode !== PlayMode.Transmux
)
setPlayMode(PlayMode.Transmux);
else { else {
onError?.call(null, { onError?.call(null, {
error: { "": "", errorString: ref.current?.error?.message ?? "Unknown error" }, error: { "": "", errorString: ref.current?.error?.message ?? "Unknown error" },

View File

@ -43,6 +43,7 @@
"pause": "Pause", "pause": "Pause",
"mute": "Toggle mute", "mute": "Toggle mute",
"volume": "Volume", "volume": "Volume",
"quality": "Quality",
"subtitles": "Subtitles", "subtitles": "Subtitles",
"subtitle-none": "None", "subtitle-none": "None",
"fullscreen": "Fullscreen" "fullscreen": "Fullscreen"

View File

@ -43,6 +43,7 @@
"pause": "Pause", "pause": "Pause",
"mute": "Muet", "mute": "Muet",
"volume": "Volume", "volume": "Volume",
"quality": "Qualité",
"subtitles": "Sous titres", "subtitles": "Sous titres",
"subtitle-none": "Aucun", "subtitle-none": "Aucun",
"fullscreen": "Plein-écran" "fullscreen": "Plein-écran"