mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add transmux support to the player
This commit is contained in:
parent
6534f3bd25
commit
6bcff8d9a2
@ -75,6 +75,11 @@ namespace Kyoo.Abstractions.Models
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The summary of this episode.
|
||||
/// </summay>
|
||||
public string Overview { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The release date of this episode. It can be null if unknown.
|
||||
/// </summary>
|
||||
@ -211,6 +216,7 @@ namespace Kyoo.Abstractions.Models
|
||||
EpisodeNumber = ep.EpisodeNumber,
|
||||
AbsoluteNumber = ep.AbsoluteNumber,
|
||||
Title = ep.Title,
|
||||
Overview = ep.Overview,
|
||||
ReleaseDate = ep.ReleaseDate,
|
||||
Path = ep.Path,
|
||||
Images = ep.Show.Images,
|
||||
|
@ -26,6 +26,7 @@
|
||||
"@jellyfin/libass-wasm": "^4.1.1",
|
||||
"@mui/icons-material": "^5.8.4",
|
||||
"@mui/material": "^5.8.7",
|
||||
"hls.js": "^1.2.4",
|
||||
"jotai": "^1.8.4",
|
||||
"next": "12.2.2",
|
||||
"next-translate": "^1.5.0",
|
||||
|
@ -120,6 +120,10 @@ const WatchMovieP = z.preprocess(
|
||||
* The title of this episode.
|
||||
*/
|
||||
name: z.string(),
|
||||
/**
|
||||
* The sumarry of this episode.
|
||||
*/
|
||||
overview: z.string().nullable(),
|
||||
/**
|
||||
* The release date of this episode. It can be null if unknown.
|
||||
*/
|
||||
|
@ -25,9 +25,13 @@ import { useFetch } from "~/utils/query";
|
||||
import { ErrorPage } from "~/components/errors";
|
||||
import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Hover, LoadingIndicator } from "./components/hover";
|
||||
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
|
||||
// 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 { data, error } = useFetch(query(slug));
|
||||
const { playerRef, videoProps, onVideoClick } = useVideoController();
|
||||
const { playerRef, videoProps, onVideoClick } = useVideoController(data?.link);
|
||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const isPlaying = useAtomValue(playAtom);
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
const [showHover, setHover] = useState(false);
|
||||
const [mouseMoved, setMouseMoved] = useState(false);
|
||||
const [menuOpenned, setMenuOpen] = useState(false);
|
||||
@ -55,7 +60,7 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
mouseCallback = setTimeout(() => {
|
||||
setMouseMoved(false);
|
||||
}, 2500);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: PointerEvent) => {
|
||||
@ -67,6 +72,10 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
return () => document.removeEventListener("pointermove", handler);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPlay(true);
|
||||
}, [slug, setPlay]);
|
||||
|
||||
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
|
||||
|
||||
useEffect(() => {
|
||||
@ -79,6 +88,24 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
|
||||
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>{`
|
||||
::cue {
|
||||
background-color: transparent;
|
||||
@ -91,7 +118,6 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
>
|
||||
<Box
|
||||
component="video"
|
||||
src={data?.link.direct}
|
||||
{...videoProps}
|
||||
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
|
||||
if (e.pointerType === "mouse") {
|
||||
@ -102,6 +128,14 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
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={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
|
@ -19,20 +19,33 @@
|
||||
*/
|
||||
|
||||
import { BoxProps } from "@mui/material";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { atom, useAtom, useSetAtom } from "jotai";
|
||||
import { useRouter } from "next/router";
|
||||
import { RefObject, useEffect, useRef } from "react";
|
||||
import { Font, Track } from "~/models/resources/watch-item";
|
||||
import { bakedAtom } from "~/utils/jotai-utils";
|
||||
// @ts-ignore
|
||||
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 [_playAtom, playAtom] = bakedAtom(true, (get, _, value) => {
|
||||
export const [_playAtom, playAtom] = bakedAtom(true, async (get, set, value) => {
|
||||
const player = get(playerAtom);
|
||||
if (!player?.current) return;
|
||||
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 {
|
||||
player.current.pause();
|
||||
}
|
||||
@ -71,10 +84,13 @@ export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker)
|
||||
} catch {}
|
||||
});
|
||||
|
||||
export const useVideoController = () => {
|
||||
let hls: Hls | null = null;
|
||||
|
||||
export const useVideoController = (links?: { direct: string; transmux: string }) => {
|
||||
const player = useRef<HTMLVideoElement>(null);
|
||||
const setPlayer = useSetAtom(playerAtom);
|
||||
const setPlay = useSetAtom(_playAtom);
|
||||
const setPPlay = useSetAtom(playAtom);
|
||||
const setLoad = useSetAtom(loadAtom);
|
||||
const setProgress = useSetAtom(_progressAtom);
|
||||
const setBuffered = useSetAtom(bufferedAtom);
|
||||
@ -82,15 +98,40 @@ export const useVideoController = () => {
|
||||
const setVolume = useSetAtom(_volumeAtom);
|
||||
const setMuted = useSetAtom(_mutedAtom);
|
||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||
const [playMode, setPlayMode] = useAtom(playModeAtom);
|
||||
|
||||
setPlayer(player);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player.current) return;
|
||||
if (player.current.paused) player.current.play();
|
||||
setPlay(!player.current.paused);
|
||||
}, [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(() => {
|
||||
if (!player?.current?.duration) return;
|
||||
setDuration(player.current.duration);
|
||||
@ -109,6 +150,10 @@ export const useVideoController = () => {
|
||||
onPause: () => setPlay(false),
|
||||
onWaiting: () => setLoad(true),
|
||||
onCanPlay: () => setLoad(false),
|
||||
onError: () => {
|
||||
if (player?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
|
||||
setPlayMode(PlayMode.Transmux);
|
||||
},
|
||||
onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
|
||||
onDurationChange: () => setDuration(player?.current?.duration ?? 0),
|
||||
onProgress: () =>
|
||||
@ -130,11 +175,7 @@ export const useVideoController = () => {
|
||||
videoProps,
|
||||
onVideoClick: () => {
|
||||
if (!player.current) return;
|
||||
if (player.current.paused) {
|
||||
player.current.play();
|
||||
} else {
|
||||
player.current.pause();
|
||||
}
|
||||
setPPlay(player.current.paused);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -1460,6 +1460,11 @@ has@^1.0.3:
|
||||
dependencies:
|
||||
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:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||
|
Loading…
x
Reference in New Issue
Block a user