Add a video player

This commit is contained in:
Zoe Roux 2022-10-05 11:07:37 +09:00
parent 5dfd65c16f
commit 251b80b152
12 changed files with 770 additions and 7 deletions

View File

@ -206,7 +206,8 @@ namespace Kyoo.Abstractions.Models
Subtitles = ep.Tracks.Where(x => x.Type == StreamType.Subtitle).ToArray(),
PreviousEpisode = previous,
NextEpisode = next,
Chapters = await _GetChapters(ep.Path)
Chapters = await _GetChapters(ep.Path),
IsMovie = ep.Show.IsMovie
};
}

View File

@ -0,0 +1,11 @@
{
"back": "Back",
"previous": "Previous episode",
"next": "Next episode",
"play": "Play",
"pause": "Pause",
"mute": "Toggle mute",
"volume": "Volume",
"subtitles": "Subtitles",
"fullscreen": "Fullscreen"
}

View File

@ -0,0 +1,11 @@
{
"back": "Retour",
"previous": "Episode précédent",
"next": "Episode suivant",
"play": "Jouer",
"pause": "Pause",
"mute": "Muet",
"volume": "Volume",
"subtitles": "Sous titres",
"fullscreen": "Plein-écran"
}

View File

@ -24,11 +24,14 @@ import { Episode } from "~/models";
import { Link } from "~/utils/link";
import { Image } from "./poster";
const displayNumber = (episode: Episode) => {
export const episodeDisplayNumber = (
episode: { seasonNumber?: number; episodeNumber?: number; absoluteNumber?: number },
def?: string,
) => {
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
return "???";
return def;
};
export const EpisodeBox = ({ episode, sx }: { episode?: Episode; sx: SxProps }) => {
@ -59,7 +62,7 @@ export const EpisodeLine = ({ episode, sx }: { episode?: Episode; sx?: SxProps }
}}
>
<Typography variant="overline" align="center" sx={{ width: "4rem", flexShrink: 0 }}>
{episode ? displayNumber(episode) : <Skeleton />}
{episode ? episodeDisplayNumber(episode, "???") : <Skeleton />}
</Typography>
<Image
img={episode?.thumbnail}

View File

@ -38,7 +38,7 @@ export const ErrorComponent = ({ errors, sx }: { errors: string[]; sx?: SxProps
<Typography variant="h1" component="h1" sx={{ fontWeight: 500 }}>
Error
</Typography>
{errors.map((x, i) => (
{errors?.map((x, i) => (
<Typography variant="h2" component="h2" key={i}>
{x}
</Typography>

View File

@ -33,7 +33,6 @@ import {
} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import useTranslation from "next-translate/useTranslation";
import Image from "next/image";
import { ButtonLink } from "~/utils/link";
import { Library, LibraryP, Page, Paged } from "~/models";
import { QueryIdentifier, useFetch } from "~/utils/query";

View File

@ -25,6 +25,7 @@ import { ResourceP } from "../traits/resource";
export const EpisodeP = z.preprocess(
(x: any) => {
if (!x) return x;
x.name = x.title;
return x;
},

View File

@ -37,6 +37,7 @@ export enum Status {
export const ShowP = z.preprocess(
(x: any) => {
if (!x) return x;
// Waiting for the API to be updaded
x.name = x.title;
if (x.aliases === null) x.aliases = [];

View File

@ -0,0 +1,181 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { z } from "zod";
import { zdate } from "~/utils/zod";
import { ResourceP, ImagesP } from "../traits";
import { EpisodeP } from "./episode";
/**
* A video, audio or subtitle track for an episode.
*/
export const TrackP = ResourceP.extend({
/**
* The title of the stream.
*/
title: z.string().nullable(),
/**
* The language of this stream (as a ISO-639-2 language code)
*/
language: z.string().nullable(),
/**
* The codec of this stream.
*/
codec: z.string(),
/**
* Is this stream the default one of it's type?
*/
isDefault: z.boolean(),
/**
* Is this stream tagged as forced?
*/
isForced: z.boolean(),
/**
* Is this track extern to the episode's file?
*/
isExternal: z.boolean(),
/**
* The index of this track on the episode.
*/
trackIndex: z.number(),
/**
* A user-friendly name for this track. It does not include the track type.
*/
displayName: z.string(),
});
export const ChapterP = z.object({
/**
* The start time of the chapter (in second from the start of the episode).
*/
startTime: z.number(),
/**
* The end time of the chapter (in second from the start of the episode).
*/
endTime: z.number(),
/**
* The name of this chapter. This should be a human-readable name that could be presented to the
* user. There should be well-known chapters name for commonly used chapters. For example, use
* "Opening" for the introduction-song and "Credits" for the end chapter with credits.
*/
name: z.string(),
});
export type Chapter = z.infer<typeof ChapterP>;
const WatchMovieP = z.preprocess(
(x: any) => {
if (!x) return x;
x.name = x.title;
x.link = {
direct: `/api/video/${x.slug}`,
};
return x;
},
ImagesP.extend({
/**
* The slug of this episode.
*/
slug: z.string(),
/**
* The title of this episode.
*/
name: z.string(),
/**
* The release date of this episode. It can be null if unknown.
*/
releaseDate: zdate().nullable(),
/**
* The container of the video file of this episode. Common containers are mp4, mkv, avi and so on.
*/
container: z.string(),
/**
* The video track. See Track for more information.
*/
video: TrackP,
/**
* The list of audio tracks. See Track for more information.
*/
audios: z.array(TrackP),
/**
* The list of subtitles tracks. See Track for more information.
*/
subtitles: z.array(TrackP),
/**
* The list of chapters. See Chapter for more information.
*/
chapters: z.array(ChapterP),
/**
* The links to the videos of this watch item.
*/
link: z.object({
direct: z.string(),
}),
}),
);
const WatchEpisodeP = WatchMovieP.and(
z.object({
/**
* The ID of the episode associated with this item.
*/
episodeID: z.number(),
/**
* The title of the show containing this episode.
*/
showTitle: z.string(),
/**
* The slug of the show containing this episode
*/
showSlug: z.string(),
/**
* The season in witch this episode is in.
*/
seasonNumber: z.number(),
/**
* The number of this episode is it's season.
*/
episodeNumber: z.number(),
/**
* The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
*/
absoluteNumber: z.number(),
/**
* The episode that come before this one if you follow usual watch orders. If this is the first
* episode or this is a movie, it will be null.
*/
previousEpisode: EpisodeP.nullable(),
/**
* The episode that come after this one if you follow usual watch orders. If this is the last
* aired episode or this is a movie, it will be null.
*/
nextEpisode: EpisodeP.nullable(),
}),
);
export const WatchItemP = z.union([
WatchMovieP.and(z.object({ isMovie: z.literal(true) })),
WatchEpisodeP.and(z.object({ isMovie: z.literal(false) })),
]);
/**
* A watch item for a movie or an episode
*/
export type WatchItem = z.infer<typeof WatchItemP>;

View File

@ -91,7 +91,7 @@ export default appWithI18n(App as any, {
defaultLocale: "en",
loader: false,
pages: {
"*": ["common", "browse"],
"*": ["common", "browse", "player"],
},
loadLocaleFrom: (locale, namespace) =>
import(`../../locales/${locale}/${namespace}`).then((m) => m.default),

View File

@ -0,0 +1,549 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { QueryIdentifier, QueryPage } from "~/utils/query";
import { withRoute } from "~/utils/router";
import { WatchItem, WatchItemP, Chapter } from "~/models/resources/watch-item";
import { useFetch } from "~/utils/query";
import { ErrorPage } from "~/components/errors";
import { useState, useRef, useEffect, HTMLProps, memo, useMemo, useCallback } from "react";
import {
Box,
CircularProgress,
IconButton,
Tooltip,
Typography,
Skeleton,
Slider,
} from "@mui/material";
import useTranslation from "next-translate/useTranslation";
import {
ArrowBack,
ClosedCaption,
Fullscreen,
FullscreenExit,
Pause,
PlayArrow,
SkipNext,
SkipPrevious,
VolumeDown,
VolumeMute,
VolumeOff,
VolumeUp,
} from "@mui/icons-material";
import { Poster } from "~/components/poster";
import { episodeDisplayNumber } from "~/components/episode";
import NextLink from "next/link";
const toTimerString = (timer: number, duration?: number) => {
if (!duration) duration = timer;
if (duration >= 3600) return new Date(timer * 1000).toISOString().substring(11, 19);
return new Date(timer * 1000).toISOString().substring(14, 19);
};
const LoadingIndicator = () => {
return (
<Box
sx={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
background: "rgba(0, 0, 0, 0.3)",
display: "flex",
justifyContent: "center",
}}
>
<CircularProgress thickness={5} sx={{ color: "white", alignSelf: "center" }} />
</Box>
);
};
const ProgressBar = ({
progress,
duration,
buffered,
chapters,
setProgress,
}: {
progress: number;
duration: number;
buffered: number;
chapters?: Chapter[];
setProgress: (value: number) => void;
}) => {
const [isSeeking, setSeek] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const updateProgress = (event: MouseEvent, skipSeek?: boolean) => {
if (!(isSeeking || skipSeek) || !ref?.current) return;
const value: number = (event.pageX - ref.current.offsetLeft) / ref.current.clientWidth;
setProgress(Math.max(0, Math.min(value, 1)) * duration);
};
useEffect(() => {
const handler = () => setSeek(false);
document.addEventListener("mouseup", handler);
return () => document.removeEventListener("mouseup", handler);
});
useEffect(() => {
document.addEventListener("mousemove", updateProgress);
return () => document.removeEventListener("mousemove", updateProgress);
});
return (
<Box
onMouseDown={(event) => {
event.preventDefault();
setSeek(true);
}}
onTouchStart={() => setSeek(true)}
onClick={(event) => updateProgress(event.nativeEvent, true)}
sx={{
width: "100%",
py: 1,
cursor: "pointer",
"&:hover": {
".thumb": { opacity: 1 },
".bar": { transform: "unset" },
},
}}
>
<Box
ref={ref}
className="bar"
sx={{
width: "100%",
height: "4px",
background: "rgba(255, 255, 255, 0.2)",
transform: isSeeking ? "unset" : "scaleY(.6)",
position: "relative",
}}
>
<Box
sx={{
width: `${(buffered / duration) * 100}%`,
position: "absolute",
top: 0,
bottom: 0,
left: 0,
background: "rgba(255, 255, 255, 0.5)",
}}
/>
<Box
sx={{
width: `${(progress / duration) * 100}%`,
position: "absolute",
top: 0,
bottom: 0,
left: 0,
background: (theme) => theme.palette.primary.main,
}}
/>
<Box
className="thumb"
sx={{
position: "absolute",
left: `calc(${(progress / duration) * 100}% - 6px)`,
top: 0,
bottom: 0,
margin: "auto",
opacity: +isSeeking,
width: "12px",
height: "12px",
borderRadius: "6px",
background: (theme) => theme.palette.primary.main,
}}
/>
{chapters?.map((x) => (
<Box
key={x.startTime}
sx={{
position: "absolute",
width: "2px",
top: 0,
botton: 0,
left: `${(x.startTime / duration) * 100}%`,
background: (theme) => theme.palette.primary.dark,
}}
/>
))}
</Box>
</Box>
);
};
const VideoPoster = memo(function VideoPoster({ poster }: { poster?: string | null }) {
return (
<Box
sx={{
width: "15%",
display: { xs: "none", sm: "block" },
position: "relative",
}}
>
<Poster img={poster} width="100%" sx={{ position: "absolute", bottom: 0 }} />
</Box>
);
});
const LeftButtons = memo(function LeftButtons({
previousSlug,
nextSlug,
isPlaying,
isMuted,
volume,
togglePlay,
toggleMute,
setVolume,
}: {
previousSlug?: string;
nextSlug?: string;
isPlaying: boolean;
isMuted: boolean;
volume: number;
togglePlay: () => void;
toggleMute: () => void;
setVolume: (value: number) => void;
}) {
const { t } = useTranslation("player");
return (
<Box sx={{ display: "flex", "> *": { mx: "8px !important" } }}>
{previousSlug && (
<Tooltip title={t("previous")}>
<NextLink href={`/watch/${previousSlug}`} passHref>
<IconButton aria-label={t("previous")} sx={{ color: "white" }}>
<SkipPrevious />
</IconButton>
</NextLink>
</Tooltip>
)}
<Tooltip title={isPlaying ? t("pause") : t("play")}>
<IconButton
onClick={togglePlay}
aria-label={isPlaying ? t("pause") : t("play")}
sx={{ color: "white" }}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
</Tooltip>
{nextSlug && (
<Tooltip title={t("next")}>
<NextLink href={`/watch/${nextSlug}`} passHref>
<IconButton aria-label={t("next")} sx={{ color: "white" }}>
<SkipNext />
</IconButton>
</NextLink>
</Tooltip>
)}
<Box
sx={{
display: "flex",
m: "0 !important",
p: "8px",
"&:hover .slider": { width: "100px", px: "16px" },
}}
>
<Tooltip title={t("mute")}>
<IconButton onClick={toggleMute} aria-label={t("mute")} sx={{ color: "white" }}>
{isMuted || volume == 0 ? (
<VolumeOff />
) : volume < 25 ? (
<VolumeMute />
) : volume < 65 ? (
<VolumeDown />
) : (
<VolumeUp />
)}
</IconButton>
</Tooltip>
<Box
className="slider"
sx={{
width: 0,
transition:
"width .2s cubic-bezier(0.4,0, 1, 1), padding .2s cubic-bezier(0.4,0, 1, 1)",
overflow: "hidden",
alignSelf: "center",
}}
>
<Slider
value={volume}
onChange={(_, value) => setVolume(value as number)}
size="small"
aria-label={t("volume")}
sx={{ alignSelf: "center" }}
/>
</Box>
</Box>
</Box>
);
});
const RightButtons = memo(function RightButton({
isFullscreen,
toggleFullscreen,
}: {
isFullscreen: boolean;
toggleFullscreen: () => void;
}) {
const { t } = useTranslation("player");
return (
<Box sx={{ "> *": { mx: "8px !important" } }}>
<Tooltip title={t("subtitles")}>
<IconButton aria-label={t("subtitles")} sx={{ color: "white" }}>
<ClosedCaption />
</IconButton>
</Tooltip>
<Tooltip title={t("fullscreen")}>
<IconButton onClick={toggleFullscreen} aria-label={t("fullscreen")} sx={{ color: "white" }}>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
</Box>
);
});
const Back = memo(function Back({ name, href }: { name?: string; href: string }) {
const { t } = useTranslation("player");
return (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
background: "rgba(0, 0, 0, 0.6)",
display: "flex",
p: "0.33%",
color: "white",
}}
>
<Tooltip title={t("back")}>
<NextLink href={href} passHref>
<IconButton aria-label={t("back")} sx={{ color: "white" }}>
<ArrowBack />
</IconButton>
</NextLink>
</Tooltip>
<Typography component="h1" variant="h5" sx={{ alignSelf: "center", ml: "1rem" }}>
{name ? name : <Skeleton />}
</Typography>
</Box>
);
});
const useVideoController = () => {
const player = useRef<HTMLVideoElement>(null);
const [isPlaying, setPlay] = useState(true);
const [isLoading, setLoad] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [volume, setVolume] = useState(100);
const [isMuted, setMute] = useState(false);
const [isFullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!player?.current?.duration) return;
setDuration(player.current.duration);
}, [player]);
const togglePlay = useCallback(() => {
if (!player?.current) return;
if (!isPlaying) {
player.current.play();
} else {
player.current.pause();
}
}, [isPlaying, player]);
const toggleFullscreen = useCallback(() => {
setFullscreen(!isFullscreen);
if (isFullscreen) {
document.exitFullscreen();
} else {
document.body.requestFullscreen();
}
}, [isFullscreen]);
const videoProps: HTMLProps<HTMLVideoElement> = useMemo(
() => ({
ref: player,
onClick: togglePlay,
onDoubleClick: () => toggleFullscreen,
onPlay: () => setPlay(true),
onPause: () => setPlay(false),
onWaiting: () => setLoad(true),
onCanPlay: () => setLoad(false),
onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
onDurationChange: () => setDuration(player?.current?.duration ?? 0),
onProgress: () =>
setBuffered(
player?.current?.buffered.length
? player.current.buffered.end(player.current.buffered.length - 1)
: 0,
),
onVolumeChange: () => {
if (!player.current) return;
setVolume(player.current.volume * 100);
setMute(player?.current.muted);
},
autoPlay: true,
controls: false,
}),
[player, togglePlay, toggleFullscreen],
);
return {
state: { isPlaying, isLoading, progress, duration, buffered, volume, isMuted, isFullscreen },
videoProps,
togglePlay,
toggleMute: useCallback(() => {
if (player.current) player.current.muted = !isMuted;
}, [player, isMuted]),
toggleFullscreen,
setVolume: useCallback(
(value: number) => {
setVolume(value);
if (player.current) player.current.volume = value / 100;
},
[player],
),
setProgress: useCallback(
(value: number) => {
setProgress(value);
if (player.current) player.current.currentTime = value;
},
[player],
),
};
};
const query = (slug: string): QueryIdentifier<WatchItem> => ({
path: ["watch", slug],
parser: WatchItemP,
});
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(slug));
const {
state: { isPlaying, isLoading, progress, duration, buffered, volume, isMuted, isFullscreen },
videoProps,
togglePlay,
toggleMute,
toggleFullscreen,
setProgress,
setVolume,
} = useVideoController();
const [showHover, setHover] = useState(true);
const name = data
? data.isMovie
? data.name
: `${episodeDisplayNumber(data, "")} ${data.name}`
: undefined;
if (error) return <ErrorPage {...error} />;
return (
<>
<Box
component="video"
src={data?.link.direct}
{...(videoProps as any)}
sx={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "black",
}}
/>
{isLoading && <LoadingIndicator />}
{showHover && (
<>
<Back
name={data?.name}
href={data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#"}
/>
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
background: "rgba(0, 0, 0, 0.6)",
display: "flex",
padding: "1%",
}}
>
<VideoPoster poster={data?.poster} />
<Box sx={{ width: "100%", ml: 3, display: "flex", flexDirection: "column" }}>
<Typography variant="h4" component="h2" color="white" sx={{ pb: 1 }}>
{name ?? <Skeleton />}
</Typography>
<ProgressBar
progress={progress}
duration={duration}
buffered={buffered}
setProgress={setProgress}
chapters={data?.chapters}
/>
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between" }}>
<Box sx={{ display: "flex" }}>
<LeftButtons
previousSlug={data && !data.isMovie ? data.previousEpisode?.slug : undefined}
nextSlug={data && !data.isMovie ? data.nextEpisode?.slug : undefined}
isPlaying={isPlaying}
volume={volume}
isMuted={isMuted}
togglePlay={togglePlay}
toggleMute={toggleMute}
setVolume={setVolume}
/>
<Typography color="white" sx={{ alignSelf: "center" }}>
{toTimerString(progress, duration)} : {toTimerString(duration)}
</Typography>
</Box>
<RightButtons isFullscreen={isFullscreen} toggleFullscreen={toggleFullscreen} />
</Box>
</Box>
</Box>
</>
)}
</>
);
};
Player.getFetchUrls = ({ slug }) => [query(slug)];
export default withRoute(Player);

View File

@ -4,9 +4,15 @@ server {
location / {
proxy_pass ${FRONT_URL};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /api/ {
proxy_pass ${BACK_URL}/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}