mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-06-01 20:54:13 -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(),
|
Subtitles = ep.Tracks.Where(x => x.Type == StreamType.Subtitle).ToArray(),
|
||||||
PreviousEpisode = previous,
|
PreviousEpisode = previous,
|
||||||
NextEpisode = next,
|
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 { Link } from "~/utils/link";
|
||||||
import { Image } from "./poster";
|
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")
|
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
|
||||||
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
|
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
|
||||||
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
|
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
|
||||||
return "???";
|
return def;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EpisodeBox = ({ episode, sx }: { episode?: Episode; sx: SxProps }) => {
|
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 }}>
|
<Typography variant="overline" align="center" sx={{ width: "4rem", flexShrink: 0 }}>
|
||||||
{episode ? displayNumber(episode) : <Skeleton />}
|
{episode ? episodeDisplayNumber(episode, "???") : <Skeleton />}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Image
|
<Image
|
||||||
img={episode?.thumbnail}
|
img={episode?.thumbnail}
|
||||||
|
@ -38,7 +38,7 @@ export const ErrorComponent = ({ errors, sx }: { errors: string[]; sx?: SxProps
|
|||||||
<Typography variant="h1" component="h1" sx={{ fontWeight: 500 }}>
|
<Typography variant="h1" component="h1" sx={{ fontWeight: 500 }}>
|
||||||
Error
|
Error
|
||||||
</Typography>
|
</Typography>
|
||||||
{errors.map((x, i) => (
|
{errors?.map((x, i) => (
|
||||||
<Typography variant="h2" component="h2" key={i}>
|
<Typography variant="h2" component="h2" key={i}>
|
||||||
{x}
|
{x}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -33,7 +33,6 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MenuIcon from "@mui/icons-material/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import useTranslation from "next-translate/useTranslation";
|
import useTranslation from "next-translate/useTranslation";
|
||||||
import Image from "next/image";
|
|
||||||
import { ButtonLink } from "~/utils/link";
|
import { ButtonLink } from "~/utils/link";
|
||||||
import { Library, LibraryP, Page, Paged } from "~/models";
|
import { Library, LibraryP, Page, Paged } from "~/models";
|
||||||
import { QueryIdentifier, useFetch } from "~/utils/query";
|
import { QueryIdentifier, useFetch } from "~/utils/query";
|
||||||
|
@ -25,6 +25,7 @@ import { ResourceP } from "../traits/resource";
|
|||||||
|
|
||||||
export const EpisodeP = z.preprocess(
|
export const EpisodeP = z.preprocess(
|
||||||
(x: any) => {
|
(x: any) => {
|
||||||
|
if (!x) return x;
|
||||||
x.name = x.title;
|
x.name = x.title;
|
||||||
return x;
|
return x;
|
||||||
},
|
},
|
||||||
|
@ -37,6 +37,7 @@ export enum Status {
|
|||||||
|
|
||||||
export const ShowP = z.preprocess(
|
export const ShowP = z.preprocess(
|
||||||
(x: any) => {
|
(x: any) => {
|
||||||
|
if (!x) return x;
|
||||||
// Waiting for the API to be updaded
|
// Waiting for the API to be updaded
|
||||||
x.name = x.title;
|
x.name = x.title;
|
||||||
if (x.aliases === null) x.aliases = [];
|
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",
|
defaultLocale: "en",
|
||||||
loader: false,
|
loader: false,
|
||||||
pages: {
|
pages: {
|
||||||
"*": ["common", "browse"],
|
"*": ["common", "browse", "player"],
|
||||||
},
|
},
|
||||||
loadLocaleFrom: (locale, namespace) =>
|
loadLocaleFrom: (locale, namespace) =>
|
||||||
import(`../../locales/${locale}/${namespace}`).then((m) => m.default),
|
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 / {
|
location / {
|
||||||
proxy_pass ${FRONT_URL};
|
proxy_pass ${FRONT_URL};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass ${BACK_URL}/;
|
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