Add transmux support to the player

This commit is contained in:
Zoe Roux 2022-10-10 18:47:34 +09:00
parent 6534f3bd25
commit 6bcff8d9a2
6 changed files with 106 additions and 15 deletions

View File

@ -75,6 +75,11 @@ namespace Kyoo.Abstractions.Models
/// </summary> /// </summary>
public string Title { get; set; } public string Title { get; set; }
/// <summary>
/// The summary of this episode.
/// </summay>
public string Overview { get; set; }
/// <summary> /// <summary>
/// The release date of this episode. It can be null if unknown. /// The release date of this episode. It can be null if unknown.
/// </summary> /// </summary>
@ -211,6 +216,7 @@ namespace Kyoo.Abstractions.Models
EpisodeNumber = ep.EpisodeNumber, EpisodeNumber = ep.EpisodeNumber,
AbsoluteNumber = ep.AbsoluteNumber, AbsoluteNumber = ep.AbsoluteNumber,
Title = ep.Title, Title = ep.Title,
Overview = ep.Overview,
ReleaseDate = ep.ReleaseDate, ReleaseDate = ep.ReleaseDate,
Path = ep.Path, Path = ep.Path,
Images = ep.Show.Images, Images = ep.Show.Images,

View File

@ -26,6 +26,7 @@
"@jellyfin/libass-wasm": "^4.1.1", "@jellyfin/libass-wasm": "^4.1.1",
"@mui/icons-material": "^5.8.4", "@mui/icons-material": "^5.8.4",
"@mui/material": "^5.8.7", "@mui/material": "^5.8.7",
"hls.js": "^1.2.4",
"jotai": "^1.8.4", "jotai": "^1.8.4",
"next": "12.2.2", "next": "12.2.2",
"next-translate": "^1.5.0", "next-translate": "^1.5.0",

View File

@ -120,6 +120,10 @@ const WatchMovieP = z.preprocess(
* The title of this episode. * The title of this episode.
*/ */
name: z.string(), name: z.string(),
/**
* The sumarry of this episode.
*/
overview: z.string().nullable(),
/** /**
* The release date of this episode. It can be null if unknown. * The release date of this episode. It can be null if unknown.
*/ */

View File

@ -25,9 +25,13 @@ import { useFetch } from "~/utils/query";
import { ErrorPage } from "~/components/errors"; import { ErrorPage } from "~/components/errors";
import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react"; import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Hover, LoadingIndicator } from "./components/hover"; import { Hover, LoadingIndicator } from "./components/hover";
import { fullscreenAtom, playAtom, useSubtitleController, useVideoController } from "./state"; import { fullscreenAtom, playAtom, useSubtitleController, useVideoController } from "./state";
import { useRouter } from "next/router";
import Head from "next/head";
import { makeTitle } from "~/utils/utils";
import { episodeDisplayNumber } from "~/components/episode";
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout // Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move) // if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
@ -40,10 +44,11 @@ const query = (slug: string): QueryIdentifier<WatchItem> => ({
const Player: QueryPage<{ slug: string }> = ({ slug }) => { const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(slug)); const { data, error } = useFetch(query(slug));
const { playerRef, videoProps, onVideoClick } = useVideoController(); const { playerRef, videoProps, onVideoClick } = useVideoController(data?.link);
const setFullscreen = useSetAtom(fullscreenAtom); const setFullscreen = useSetAtom(fullscreenAtom);
const router = useRouter();
const isPlaying = useAtomValue(playAtom); const [isPlaying, setPlay] = useAtom(playAtom);
const [showHover, setHover] = useState(false); const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false); const [mouseMoved, setMouseMoved] = useState(false);
const [menuOpenned, setMenuOpen] = useState(false); const [menuOpenned, setMenuOpen] = useState(false);
@ -55,7 +60,7 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
mouseCallback = setTimeout(() => { mouseCallback = setTimeout(() => {
setMouseMoved(false); setMouseMoved(false);
}, 2500); }, 2500);
} };
useEffect(() => { useEffect(() => {
const handler = (e: PointerEvent) => { const handler = (e: PointerEvent) => {
@ -67,6 +72,10 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
return () => document.removeEventListener("pointermove", handler); return () => document.removeEventListener("pointermove", handler);
}); });
useEffect(() => {
setPlay(true);
}, [slug, setPlay]);
useSubtitleController(playerRef, data?.subtitles, data?.fonts); useSubtitleController(playerRef, data?.subtitles, data?.fonts);
useEffect(() => { useEffect(() => {
@ -79,6 +88,24 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
return ( return (
<> <>
{data && (
<Head>
<title>
{makeTitle(
data.isMovie
? data.name
: data.showTitle +
" " +
episodeDisplayNumber({
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
absoluteNumber: data.absoluteNumber,
}),
)}
</title>
<meta name="description" content={data.overview ?? undefined} />
</Head>
)}
<style jsx global>{` <style jsx global>{`
::cue { ::cue {
background-color: transparent; background-color: transparent;
@ -91,7 +118,6 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
> >
<Box <Box
component="video" component="video"
src={data?.link.direct}
{...videoProps} {...videoProps}
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => { onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
if (e.pointerType === "mouse") { if (e.pointerType === "mouse") {
@ -102,6 +128,14 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
mouseHasMoved(); mouseHasMoved();
} }
}} }}
onEnded={() => {
if (!data) return;
if (data.isMovie) router.push(`/movie/${data.slug}`);
else
router.push(
data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : `/show/${data.showSlug}`,
);
}}
sx={{ sx={{
position: "fixed", position: "fixed",
top: 0, top: 0,

View File

@ -19,20 +19,33 @@
*/ */
import { BoxProps } from "@mui/material"; import { BoxProps } from "@mui/material";
import { atom, useSetAtom } from "jotai"; import { atom, useAtom, useSetAtom } from "jotai";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { RefObject, useEffect, useRef } from "react"; import { RefObject, useEffect, useRef } from "react";
import { Font, Track } from "~/models/resources/watch-item"; import { Font, Track } from "~/models/resources/watch-item";
import { bakedAtom } from "~/utils/jotai-utils"; import { bakedAtom } from "~/utils/jotai-utils";
// @ts-ignore // @ts-ignore
import SubtitleOctopus from "@jellyfin/libass-wasm/dist/js/subtitles-octopus"; import SubtitleOctopus from "@jellyfin/libass-wasm/dist/js/subtitles-octopus";
import Hls from "hls.js";
enum PlayMode {
Direct,
Transmux,
}
const playModeAtom = atom<PlayMode>(PlayMode.Direct);
export const playerAtom = atom<RefObject<HTMLVideoElement> | null>(null); export const playerAtom = atom<RefObject<HTMLVideoElement> | null>(null);
export const [_playAtom, playAtom] = bakedAtom(true, (get, _, value) => { export const [_playAtom, playAtom] = bakedAtom(true, async (get, set, value) => {
const player = get(playerAtom); const player = get(playerAtom);
if (!player?.current) return; if (!player?.current) return;
if (value) { if (value) {
player.current.play(); try {
await player.current.play();
} catch (e) {
if (e instanceof DOMException && e.name === "NotSupportedError") set(playModeAtom, PlayMode.Transmux);
else if (!(e instanceof DOMException && e.name === "NotAllowedError")) console.log(e);
}
} else { } else {
player.current.pause(); player.current.pause();
} }
@ -71,10 +84,13 @@ export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker)
} catch {} } catch {}
}); });
export const useVideoController = () => { let hls: Hls | null = null;
export const useVideoController = (links?: { direct: string; transmux: string }) => {
const player = useRef<HTMLVideoElement>(null); const player = useRef<HTMLVideoElement>(null);
const setPlayer = useSetAtom(playerAtom); const setPlayer = useSetAtom(playerAtom);
const setPlay = useSetAtom(_playAtom); const setPlay = useSetAtom(_playAtom);
const setPPlay = useSetAtom(playAtom);
const setLoad = useSetAtom(loadAtom); const setLoad = useSetAtom(loadAtom);
const setProgress = useSetAtom(_progressAtom); const setProgress = useSetAtom(_progressAtom);
const setBuffered = useSetAtom(bufferedAtom); const setBuffered = useSetAtom(bufferedAtom);
@ -82,15 +98,40 @@ export const useVideoController = () => {
const setVolume = useSetAtom(_volumeAtom); const setVolume = useSetAtom(_volumeAtom);
const setMuted = useSetAtom(_mutedAtom); const setMuted = useSetAtom(_mutedAtom);
const setFullscreen = useSetAtom(fullscreenAtom); const setFullscreen = useSetAtom(fullscreenAtom);
const [playMode, setPlayMode] = useAtom(playModeAtom);
setPlayer(player); setPlayer(player);
useEffect(() => { useEffect(() => {
if (!player.current) return; if (!player.current) return;
if (player.current.paused) player.current.play();
setPlay(!player.current.paused); setPlay(!player.current.paused);
}, [setPlay]); }, [setPlay]);
useEffect(() => {
setPlayMode(PlayMode.Direct);
}, [links, setPlayMode]);
useEffect(() => {
const src = playMode === PlayMode.Direct ? links?.direct : links?.transmux;
if (!player?.current || !src) return;
if (
playMode == PlayMode.Direct ||
player.current.canPlayType("application/vnd.apple.mpegurl")
) {
player.current.src = src;
} else {
if (hls === null) hls = new Hls();
hls.loadSource(src);
hls.attachMedia(player.current);
hls.on(Hls.Events.MANIFEST_LOADED, async () => {
try {
await player.current?.play();
} catch {}
});
}
}, [playMode, links, player]);
useEffect(() => { useEffect(() => {
if (!player?.current?.duration) return; if (!player?.current?.duration) return;
setDuration(player.current.duration); setDuration(player.current.duration);
@ -109,6 +150,10 @@ export const useVideoController = () => {
onPause: () => setPlay(false), onPause: () => setPlay(false),
onWaiting: () => setLoad(true), onWaiting: () => setLoad(true),
onCanPlay: () => setLoad(false), onCanPlay: () => setLoad(false),
onError: () => {
if (player?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
setPlayMode(PlayMode.Transmux);
},
onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0), onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
onDurationChange: () => setDuration(player?.current?.duration ?? 0), onDurationChange: () => setDuration(player?.current?.duration ?? 0),
onProgress: () => onProgress: () =>
@ -130,11 +175,7 @@ export const useVideoController = () => {
videoProps, videoProps,
onVideoClick: () => { onVideoClick: () => {
if (!player.current) return; if (!player.current) return;
if (player.current.paused) { setPPlay(player.current.paused);
player.current.play();
} else {
player.current.pause();
}
}, },
}; };
}; };

View File

@ -1460,6 +1460,11 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
hls.js@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.2.4.tgz#761d7f700c3da5c1701257c24c1db7ababea14eb"
integrity sha512-yC3K79Kzq1W+OgjT12JxKMDXv9DbfvulppxmPBl7D04SaTyd2IwWk5eNASQV1mUaPlKbjr16yI9292qpSGo0ig==
hoist-non-react-statics@^3.3.1: hoist-non-react-statics@^3.3.1:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"