From 95133deeb05d417c4ee1681ae1022e297aad0cb5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 5 May 2023 01:49:20 +0900 Subject: [PATCH] Add audio menu and rework qualities menu --- .../src/Kyoo.Abstractions/Models/WatchItem.cs | 19 +-- front/packages/models/src/login.ts | 1 - .../models/src/resources/watch-item.ts | 11 +- front/packages/primitives/src/menu.tsx | 2 +- .../src/player/components/right-buttons.tsx | 28 ++-- front/packages/ui/src/player/state.tsx | 29 ++-- front/packages/ui/src/player/video.tsx | 17 ++- front/packages/ui/src/player/video.web.tsx | 126 +++++++++++++++--- front/translations/en.json | 5 +- front/translations/fr.json | 5 +- 10 files changed, 166 insertions(+), 77 deletions(-) diff --git a/back/src/Kyoo.Abstractions/Models/WatchItem.cs b/back/src/Kyoo.Abstractions/Models/WatchItem.cs index 9a5d49fd..13e61ae0 100644 --- a/back/src/Kyoo.Abstractions/Models/WatchItem.cs +++ b/back/src/Kyoo.Abstractions/Models/WatchItem.cs @@ -141,23 +141,14 @@ namespace Kyoo.Abstractions.Models /// public ICollection Chapters { get; set; } - string _Type => IsMovie ? "movie" : "episode"; + [SerializeIgnore] + private string _Type => IsMovie ? "movie" : "episode"; /// - public object Link => new[] + public object Link => new { - 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}/master.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" }, + Direct = $"/video/{_Type}/{Slug}/direct", + Hls = $"/video/{_Type}/{Slug}/master.m3u8", }; /// diff --git a/front/packages/models/src/login.ts b/front/packages/models/src/login.ts index 513170c2..6ade564a 100644 --- a/front/packages/models/src/login.ts +++ b/front/packages/models/src/login.ts @@ -78,7 +78,6 @@ export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [n export const getToken = async (cookies?: string): Promise => (await getTokenWJ(cookies))[0] - export const logout = async () =>{ deleteSecureItem("auth") } diff --git a/front/packages/models/src/resources/watch-item.ts b/front/packages/models/src/resources/watch-item.ts index f5761fb4..76995234 100644 --- a/front/packages/models/src/resources/watch-item.ts +++ b/front/packages/models/src/resources/watch-item.ts @@ -156,13 +156,10 @@ const WatchMovieP = z.preprocess( /** * The links to the videos of this watch item. */ - link: z.array( - z.object({ - name: z.string(), - link: z.string().transform(imageFn), - type: z.enum(["direct", "transmux", "transcode-auto", "transcode"]) - }), - ), + link: z.object({ + direct: z.string().transform(imageFn), + hls: z.string().transform(imageFn), + }), }), ); diff --git a/front/packages/primitives/src/menu.tsx b/front/packages/primitives/src/menu.tsx index ffc52b86..a1c4b4ba 100644 --- a/front/packages/primitives/src/menu.tsx +++ b/front/packages/primitives/src/menu.tsx @@ -43,7 +43,7 @@ const Menu = ({ ...props }: { Trigger: ComponentType; - children: ReactNode | ReactNode[] | null; + children?: ReactNode | ReactNode[] | null; onMenuOpen?: () => void; onMenuClose?: () => void; } & Omit) => { diff --git a/front/packages/ui/src/player/components/right-buttons.tsx b/front/packages/ui/src/player/components/right-buttons.tsx index fdd9f8e1..e64a7208 100644 --- a/front/packages/ui/src/player/components/right-buttons.tsx +++ b/front/packages/ui/src/player/components/right-buttons.tsx @@ -28,8 +28,10 @@ import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill 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 MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg"; import { Stylable, useYoshiki } from "yoshiki/native"; -import { fullscreenAtom, qualityAtom, subtitleAtom } from "../state"; +import { fullscreenAtom, subtitleAtom } from "../state"; +import { AudiosMenu, QualitiesMenu } from "../video"; export const RightButtons = ({ subtitles, @@ -49,7 +51,6 @@ 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(() => { @@ -64,7 +65,7 @@ export const RightButtons = ({ return ( - {subtitles && ( + {subtitles && subtitles.length && ( )} - + - {qualities?.map((x) => ( - setQuality(x.name)} - /> - ))} - + /> {Platform.OS === "web" && ( ("Pristine"); +export enum PlayMode { + Direct, + Hls, +} +export const playModeAtom = atom(PlayMode.Direct); export const bufferedAtom = atom(0); export const durationAtom = atom(undefined); @@ -78,8 +83,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 [source, setSource] = useState(null); + const [mode, setPlayMode] = useAtom(playModeAtom); const publicProgress = useAtomValue(publicProgressAtom); const setPrivateProgress = useSetAtom(privateProgressAtom); @@ -91,11 +96,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) + setSource((mode === PlayMode.Direct ? links?.direct : links?.hls) ?? null); setLoad(true); setPrivateProgress(0); setPlay(true); - }, [quality, links, setLoad, setPrivateProgress, setPlay]); + }, [mode, links, setLoad, setPrivateProgress, setPlay]); const volume = useAtomValue(volumeAtom); const isMuted = useAtomValue(mutedAtom); @@ -112,12 +117,12 @@ export const Video = memo(function _Video({ const subtitle = useAtomValue(subtitleAtom); - if (!source) return null; + if (!source || !links) 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!) - + if (mode == PlayMode.Direct) + setPlayMode(PlayMode.Hls); }} - // TODO: textTracks: external subtitles /> ); diff --git a/front/packages/ui/src/player/video.tsx b/front/packages/ui/src/player/video.tsx index 8b5645d9..8094c4bb 100644 --- a/front/packages/ui/src/player/video.tsx +++ b/front/packages/ui/src/player/video.tsx @@ -25,12 +25,25 @@ declare module "react-native-video" { onMediaUnsupported?: () => void; } export type VideoProps = Omit & { - source: { uri: string } & WatchItem["link"][0]; + source: { uri: string; hls: string }; }; } export * from "react-native-video"; -import { Font, WatchItem } from "@kyoo/models"; +import { Font } from "@kyoo/models"; +import { IconButton, Menu } from "@kyoo/primitives"; +import { ComponentProps } from "react"; import Video from "react-native-video"; export default Video; + +// TODO: Implement those for mobile. + +type CustomMenu = ComponentProps>>; +export const AudiosMenu = (props: CustomMenu) => { + return ; +}; + +export const QualitiesMenu = (props: CustomMenu) => { + return ; +}; diff --git a/front/packages/ui/src/player/video.web.tsx b/front/packages/ui/src/player/video.web.tsx index 19e473ca..5be9e03e 100644 --- a/front/packages/ui/src/player/video.web.tsx +++ b/front/packages/ui/src/player/video.web.tsx @@ -26,15 +26,19 @@ import { useImperativeHandle, useLayoutEffect, useRef, + useReducer, + ComponentProps, } from "react"; import { VideoProps } from "react-native-video"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom, useAtom } from "jotai"; import { useYoshiki } from "yoshiki"; import SubtitleOctopus from "libass-wasm"; -import { playAtom, subtitleAtom } from "./state"; +import { playAtom, PlayMode, playModeAtom, subtitleAtom } from "./state"; import Hls from "hls.js"; +import { useTranslation } from "react-i18next"; +import { Menu } from "@kyoo/primitives"; -let hls: Hls | null = null; +let hls: Hls = 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 @@ -45,6 +49,17 @@ function uuidv4(): string { let client_id = typeof window === "undefined" ? "ssr" : uuidv4(); +const initHls = async () => { + if (hls !== null) return; + const token = await getToken(); + hls = new Hls({ + xhrSetup: (xhr) => { + if (token) xhr.setRequestHeader("Authorization", `Bearer: {token}`); + xhr.setRequestHeader("X-CLIENT-ID", client_id); + }, + }); +}; + const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function _Video( { source, @@ -62,8 +77,8 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function }, forwaredRef, ) { - const { uri, type } = source; const ref = useRef(null); + const oldHls = useRef(null); const { css } = useYoshiki(); useImperativeHandle( @@ -78,7 +93,7 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function useEffect(() => { if (paused) ref.current?.pause(); - else ref.current?.play().catch(() => {}); + else ref.current?.play().catch(() => { }); }, [paused]); useEffect(() => { if (!ref.current || !volume) return; @@ -89,33 +104,39 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function const subtitle = useAtomValue(subtitleAtom); useSubtitle(ref, subtitle, fonts); + useLayoutEffect(() => { (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; + await initHls(); + // Still load the hls source to list available qualities. + // Note: This may ask the server to transmux the audio/video by loading the index.m3u8 + hls.loadSource(source.hls); + })(); + }, [source.hls]); + useLayoutEffect(() => { + (async () => { + if (!ref?.current || !source.uri) return; + await initHls(); + if (oldHls.current !== source.hls) { + // Still load the hls source to list available qualities. + // Note: This may ask the server to transmux the audio/video by loading the index.m3u8 + hls.loadSource(source.hls); + oldHls.current = source.hls; + } + if (!source.uri.endsWith(".m3u8")) { + hls.detachMedia(); + ref.current.src = source.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 {} + } catch { } }); } })(); - }, [uri, type]); + }, [source.uri, source.hls]); const setPlay = useSetAtom(playAtom); useEffect(() => { @@ -221,3 +242,66 @@ const useSubtitle = (player: RefObject, value: Track | null, f } }, [player, value, fonts]); }; + +export const AudiosMenu = (props: ComponentProps) => { + if (!hls || hls.audioTracks.length < 2) return null; + return ( + + {hls.audioTracks.map((x, i) => ( + (hls!.audioTrack = i)} + /> + ))} + + ); +}; + +export const QualitiesMenu = (props: ComponentProps) => { + const { t } = useTranslation(); + const [mode, setPlayMode] = useAtom(playModeAtom); + const [_, rerender] = useReducer((x) => x + 1, 0); + + useEffect(() => { + if (!hls) return; + hls.on(Hls.Events.LEVEL_SWITCHED, rerender); + return () => hls!.off(Hls.Events.LEVEL_SWITCHED, rerender); + }); + + return ( + + setPlayMode(PlayMode.Direct)} + /> + = 0 + ? `${t("player.auto")} (${hls.levels[hls.currentLevel].height}p)` + : t("player.auto") + } + selected={hls?.autoLevelEnabled && mode === PlayMode.Hls} + onSelect={() => { + setPlayMode(PlayMode.Hls); + if (hls) hls.nextLevel = -1; + }} + /> + {hls?.levels + .map((x, i) => ( + { + setPlayMode(PlayMode.Hls); + hls!.nextLevel = i; + }} + /> + )) + .reverse()} + + ); +}; diff --git a/front/translations/en.json b/front/translations/en.json index a4be8051..b5c470c6 100644 --- a/front/translations/en.json +++ b/front/translations/en.json @@ -44,9 +44,12 @@ "mute": "Toggle mute", "volume": "Volume", "quality": "Quality", + "audios": "Audio", "subtitles": "Subtitles", "subtitle-none": "None", - "fullscreen": "Fullscreen" + "fullscreen": "Fullscreen", + "direct": "Pristine", + "auto": "Auto" }, "search": { "empty": "No result found. Try a different query." diff --git a/front/translations/fr.json b/front/translations/fr.json index 50372024..6d5aa62d 100644 --- a/front/translations/fr.json +++ b/front/translations/fr.json @@ -44,9 +44,12 @@ "mute": "Muet", "volume": "Volume", "quality": "Qualité", + "audios": "Audio", "subtitles": "Sous titres", "subtitle-none": "Aucun", - "fullscreen": "Plein-écran" + "fullscreen": "Plein-écran", + "direct": "Pristine", + "auto": "Auto" }, "search": { "empty": "Aucun résultat trouvé. Essayer avec une autre recherche."