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>
|
/// </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,
|
||||||
|
@ -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",
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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,
|
||||||
|
@ -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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user