mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-08-05 08:40:04 -04:00
Add transcode quality on the front
This commit is contained in:
parent
3778b2148c
commit
9a125e0359
@ -141,11 +141,23 @@ namespace Kyoo.Abstractions.Models
|
||||
/// </summary>
|
||||
public ICollection<Chapter> Chapters { get; set; }
|
||||
|
||||
string _Type => IsMovie ? "movie" : "episode";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public object Link => new
|
||||
public object Link => new[]
|
||||
{
|
||||
Direct = $"/video/direct/{Slug}",
|
||||
Transmux = $"/video/transmux/{Slug}/master.m3u8",
|
||||
new { Name = "Pristine", Link = $"/video/{_Type}/{Slug}/direct", Type = "direct" },
|
||||
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>
|
||||
|
@ -129,7 +129,8 @@ const WatchMovieP = z.preprocess(
|
||||
*/
|
||||
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(),
|
||||
/**
|
||||
@ -155,10 +156,13 @@ const WatchMovieP = z.preprocess(
|
||||
/**
|
||||
* The links to the videos of this watch item.
|
||||
*/
|
||||
link: z.object({
|
||||
direct: z.string().transform(imageFn),
|
||||
transmux: z.string().transform(imageFn),
|
||||
}),
|
||||
link: z.array(
|
||||
z.object({
|
||||
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(),
|
||||
/**
|
||||
* 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(),
|
||||
/**
|
||||
|
@ -33,7 +33,7 @@ import {
|
||||
tooltip,
|
||||
ts,
|
||||
} 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 { View, ViewProps } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -51,6 +51,7 @@ export const Hover = ({
|
||||
href,
|
||||
poster,
|
||||
chapters,
|
||||
qualities,
|
||||
subtitles,
|
||||
fonts,
|
||||
previousSlug,
|
||||
@ -66,6 +67,7 @@ export const Hover = ({
|
||||
href?: string;
|
||||
poster?: string | null;
|
||||
chapters?: Chapter[];
|
||||
qualities?: WatchItem["link"]
|
||||
subtitles?: Track[];
|
||||
fonts?: Font[];
|
||||
previousSlug?: string | null;
|
||||
@ -117,6 +119,7 @@ export const Hover = ({
|
||||
<RightButtons
|
||||
subtitles={subtitles}
|
||||
fonts={fonts}
|
||||
qualities={qualities}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
/>
|
||||
|
@ -18,7 +18,7 @@
|
||||
* 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 { useAtom, useSetAtom } from "jotai";
|
||||
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 Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-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 { createParam } from "solito";
|
||||
import { fullscreenAtom, subtitleAtom } from "../state";
|
||||
|
||||
const { useParam } = createParam<{ subtitle?: string }>();
|
||||
import { fullscreenAtom, qualityAtom, subtitleAtom } from "../state";
|
||||
|
||||
export const RightButtons = ({
|
||||
subtitles,
|
||||
fonts,
|
||||
qualities,
|
||||
onMenuOpen,
|
||||
onMenuClose,
|
||||
...props
|
||||
}: {
|
||||
subtitles?: Track[];
|
||||
fonts?: Font[];
|
||||
qualities?: WatchItem["link"]
|
||||
onMenuOpen: () => void;
|
||||
onMenuClose: () => void;
|
||||
} & Stylable) => {
|
||||
@ -49,6 +49,7 @@ export const RightButtons = ({
|
||||
const { t } = useTranslation();
|
||||
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
|
||||
const setSubAtom = useSetAtom(subtitleAtom);
|
||||
const [quality, setQuality] = useAtom(qualityAtom);
|
||||
const [selectedSubtitle, setSubtitle] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
@ -87,6 +88,23 @@ export const RightButtons = ({
|
||||
))}
|
||||
</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" && (
|
||||
<IconButton
|
||||
icon={isFullscreen ? FullscreenExit : Fullscreen}
|
||||
|
@ -50,6 +50,7 @@ const mapData = (
|
||||
showName: data.isMovie ? data.name! : data.showTitle,
|
||||
href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#",
|
||||
poster: data.poster,
|
||||
qualities: data.link,
|
||||
subtitles: data.subtitles,
|
||||
chapters: data.chapters,
|
||||
fonts: data.fonts,
|
||||
|
@ -20,12 +20,14 @@
|
||||
|
||||
import { Track, WatchItem, Font } from "@kyoo/models";
|
||||
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 { Platform } from "react-native";
|
||||
|
||||
export const playAtom = atom(true);
|
||||
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 durationAtom = atom<number | undefined>(undefined);
|
||||
@ -63,8 +65,6 @@ const privateFullscreen = atom(false);
|
||||
|
||||
export const subtitleAtom = atom<Track | null>(null);
|
||||
|
||||
const MemoVideo = memo(NativeVideo);
|
||||
|
||||
export const Video = memo(function _Video({
|
||||
links,
|
||||
setError,
|
||||
@ -78,6 +78,8 @@ export const Video = memo(function _Video({
|
||||
const ref = useRef<NativeVideo | null>(null);
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
const setLoad = useSetAtom(loadAtom);
|
||||
const [source, setSource] = useState<WatchItem["link"][0] | null>(null);
|
||||
const [quality, setQuality] = useAtom(qualityAtom);
|
||||
|
||||
const publicProgress = useAtomValue(publicProgressAtom);
|
||||
const setPrivateProgress = useSetAtom(privateProgressAtom);
|
||||
@ -89,10 +91,11 @@ export const Video = memo(function _Video({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Reset the state when a new video is loaded.
|
||||
setSource(links?.find(x => x.name == quality) ?? null)
|
||||
setLoad(true);
|
||||
setPrivateProgress(0);
|
||||
setPlay(true);
|
||||
}, [links, setLoad, setPrivateProgress, setPlay]);
|
||||
}, [quality, links, setLoad, setPrivateProgress, setPlay]);
|
||||
|
||||
const volume = useAtomValue(volumeAtom);
|
||||
const isMuted = useAtomValue(mutedAtom);
|
||||
@ -109,13 +112,12 @@ export const Video = memo(function _Video({
|
||||
|
||||
const subtitle = useAtomValue(subtitleAtom);
|
||||
|
||||
if (!links) return null;
|
||||
if (!source) return null;
|
||||
return (
|
||||
<MemoVideo
|
||||
<NativeVideo
|
||||
ref={ref}
|
||||
{...props}
|
||||
// @ts-ignore Web only
|
||||
source={{ uri: links.direct, transmux: links.transmux }}
|
||||
source={{ uri: source.link, ...source }}
|
||||
paused={!isPlaying}
|
||||
muted={isMuted}
|
||||
volume={volume}
|
||||
@ -139,6 +141,16 @@ export const Video = memo(function _Video({
|
||||
: { type: "disabled" }
|
||||
}
|
||||
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
|
||||
/>
|
||||
);
|
||||
|
@ -18,7 +18,19 @@
|
||||
* 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";
|
||||
|
||||
import { Font, WatchItem } from "@kyoo/models";
|
||||
import Video from "react-native-video";
|
||||
export default Video;
|
||||
|
@ -18,7 +18,7 @@
|
||||
* 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 {
|
||||
forwardRef,
|
||||
RefObject,
|
||||
@ -28,30 +28,23 @@ import {
|
||||
useRef,
|
||||
} from "react";
|
||||
import { VideoProps } from "react-native-video";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useYoshiki } from "yoshiki";
|
||||
import SubtitleOctopus from "libass-wasm";
|
||||
import { playAtom, subtitleAtom } from "./state";
|
||||
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;
|
||||
|
||||
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(
|
||||
{
|
||||
source,
|
||||
@ -64,10 +57,12 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
onError,
|
||||
onEnd,
|
||||
onPlayPause,
|
||||
onMediaUnsupported,
|
||||
fonts,
|
||||
},
|
||||
forwaredRef,
|
||||
) {
|
||||
const { uri, type } = source;
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
@ -94,28 +89,33 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
const subtitle = useAtomValue(subtitleAtom);
|
||||
useSubtitle(ref, subtitle, fonts);
|
||||
|
||||
const [playMode, setPlayMode] = useAtom(playModeAtom);
|
||||
useEffect(() => {
|
||||
setPlayMode(PlayMode.Direct);
|
||||
}, [source.uri, setPlayMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const src = playMode === PlayMode.Direct ? source?.uri : source?.transmux;
|
||||
|
||||
if (!ref?.current || !src) return;
|
||||
if (playMode == PlayMode.Direct || ref.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
ref.current.src = src;
|
||||
} else {
|
||||
if (hls === null) hls = new Hls();
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(ref.current);
|
||||
hls.on(Hls.Events.MANIFEST_LOADED, async () => {
|
||||
try {
|
||||
await ref.current?.play();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
}, [playMode, source?.uri, source?.transmux]);
|
||||
(async () => {
|
||||
if (!ref?.current || !uri || !type) return;
|
||||
// TODO: Use hls.js even for safari or handle XHR requests with tokens,auto...
|
||||
if (type === "direct" || ref.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
ref.current.src = uri;
|
||||
} else {
|
||||
if (hls === null) {
|
||||
const token = await getToken();
|
||||
hls = new Hls({
|
||||
xhrSetup: (xhr) => {
|
||||
if (token) xhr.setRequestHeader("Authorization", `Bearer: {token}`);
|
||||
xhr.setRequestHeader("X-CLIENT-ID", client_id);
|
||||
},
|
||||
});
|
||||
}
|
||||
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);
|
||||
useEffect(() => {
|
||||
@ -147,11 +147,8 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
if (
|
||||
ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED &&
|
||||
playMode !== PlayMode.Transmux
|
||||
)
|
||||
setPlayMode(PlayMode.Transmux);
|
||||
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" },
|
||||
|
@ -43,6 +43,7 @@
|
||||
"pause": "Pause",
|
||||
"mute": "Toggle mute",
|
||||
"volume": "Volume",
|
||||
"quality": "Quality",
|
||||
"subtitles": "Subtitles",
|
||||
"subtitle-none": "None",
|
||||
"fullscreen": "Fullscreen"
|
||||
|
@ -43,6 +43,7 @@
|
||||
"pause": "Pause",
|
||||
"mute": "Muet",
|
||||
"volume": "Volume",
|
||||
"quality": "Qualité",
|
||||
"subtitles": "Sous titres",
|
||||
"subtitle-none": "Aucun",
|
||||
"fullscreen": "Plein-écran"
|
||||
|
Loading…
x
Reference in New Issue
Block a user