diff --git a/back/src/Kyoo.Abstractions/Models/WatchItem.cs b/back/src/Kyoo.Abstractions/Models/WatchItem.cs index 15d7ddf5..2a49d9ad 100644 --- a/back/src/Kyoo.Abstractions/Models/WatchItem.cs +++ b/back/src/Kyoo.Abstractions/Models/WatchItem.cs @@ -141,11 +141,23 @@ namespace Kyoo.Abstractions.Models /// public ICollection Chapters { get; set; } + string _Type => IsMovie ? "movie" : "episode"; + /// - 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" }, }; /// diff --git a/front/packages/models/src/resources/watch-item.ts b/front/packages/models/src/resources/watch-item.ts index e063f749..f5761fb4 100644 --- a/front/packages/models/src/resources/watch-item.ts +++ b/front/packages/models/src/resources/watch-item.ts @@ -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(), /** diff --git a/front/packages/ui/src/player/components/hover.tsx b/front/packages/ui/src/player/components/hover.tsx index e63e173b..97718cd4 100644 --- a/front/packages/ui/src/player/components/hover.tsx +++ b/front/packages/ui/src/player/components/hover.tsx @@ -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 = ({ diff --git a/front/packages/ui/src/player/components/right-buttons.tsx b/front/packages/ui/src/player/components/right-buttons.tsx index 0e7f9869..a5bf6fe7 100644 --- a/front/packages/ui/src/player/components/right-buttons.tsx +++ b/front/packages/ui/src/player/components/right-buttons.tsx @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -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(undefined); useEffect(() => { @@ -87,6 +88,23 @@ export const RightButtons = ({ ))} )} + + {qualities?.map((x) => ( + setQuality(x.name)} + /> + ))} + {Platform.OS === "web" && ( ("Pristine"); export const bufferedAtom = atom(0); export const durationAtom = atom(undefined); @@ -63,8 +65,6 @@ const privateFullscreen = atom(false); export const subtitleAtom = atom(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(null); const [isPlaying, setPlay] = useAtom(playAtom); const setLoad = useSetAtom(loadAtom); + const [source, setSource] = useState(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 ( - { + 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 /> ); diff --git a/front/packages/ui/src/player/video.tsx b/front/packages/ui/src/player/video.tsx index 26f0f7b5..8b5645d9 100644 --- a/front/packages/ui/src/player/video.tsx +++ b/front/packages/ui/src/player/video.tsx @@ -18,7 +18,19 @@ * along with Kyoo. If not, see . */ +declare module "react-native-video" { + interface VideoProperties { + fonts?: Font[]; + onPlayPause: (isPlaying: boolean) => void; + onMediaUnsupported?: () => void; + } + export type VideoProps = Omit & { + 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; diff --git a/front/packages/ui/src/player/video.web.tsx b/front/packages/ui/src/player/video.web.tsx index a9b966ee..19e473ca 100644 --- a/front/packages/ui/src/player/video.web.tsx +++ b/front/packages/ui/src/player/video.web.tsx @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -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 & { - source: { uri?: string; transmux?: string }; - }; -} - -enum PlayMode { - Direct, - Transmux, -} - -const playModeAtom = atom(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(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" }, diff --git a/front/translations/en.json b/front/translations/en.json index 6d322b56..a4be8051 100644 --- a/front/translations/en.json +++ b/front/translations/en.json @@ -43,6 +43,7 @@ "pause": "Pause", "mute": "Toggle mute", "volume": "Volume", + "quality": "Quality", "subtitles": "Subtitles", "subtitle-none": "None", "fullscreen": "Fullscreen" diff --git a/front/translations/fr.json b/front/translations/fr.json index a3cefc2c..50372024 100644 --- a/front/translations/fr.json +++ b/front/translations/fr.json @@ -43,6 +43,7 @@ "pause": "Pause", "mute": "Muet", "volume": "Volume", + "quality": "Qualité", "subtitles": "Sous titres", "subtitle-none": "Aucun", "fullscreen": "Plein-écran"