mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Add a video player
This commit is contained in:
parent
5dfd65c16f
commit
251b80b152
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
11
front/locales/en/player.json
Normal file
11
front/locales/en/player.json
Normal 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"
|
||||
}
|
11
front/locales/fr/player.json
Normal file
11
front/locales/fr/player.json
Normal 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"
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -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 = [];
|
||||
|
181
front/src/models/resources/watch-item.ts
Normal file
181
front/src/models/resources/watch-item.ts
Normal 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>;
|
@ -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),
|
||||
|
549
front/src/pages/watch/[slug].tsx
Normal file
549
front/src/pages/watch/[slug].tsx
Normal 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);
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user