diff --git a/back/src/Kyoo.Abstractions/Models/WatchItem.cs b/back/src/Kyoo.Abstractions/Models/WatchItem.cs
index 7c7afc3b..c4116545 100644
--- a/back/src/Kyoo.Abstractions/Models/WatchItem.cs
+++ b/back/src/Kyoo.Abstractions/Models/WatchItem.cs
@@ -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
};
}
diff --git a/front/locales/en/player.json b/front/locales/en/player.json
new file mode 100644
index 00000000..4b28f795
--- /dev/null
+++ b/front/locales/en/player.json
@@ -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"
+}
diff --git a/front/locales/fr/player.json b/front/locales/fr/player.json
new file mode 100644
index 00000000..adefcd01
--- /dev/null
+++ b/front/locales/fr/player.json
@@ -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"
+}
diff --git a/front/src/components/episode.tsx b/front/src/components/episode.tsx
index 4291041a..8cfd8e4d 100644
--- a/front/src/components/episode.tsx
+++ b/front/src/components/episode.tsx
@@ -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 }
}}
>
- {episode ? displayNumber(episode) : }
+ {episode ? episodeDisplayNumber(episode, "???") : }
Error
- {errors.map((x, i) => (
+ {errors?.map((x, i) => (
{x}
diff --git a/front/src/components/navbar.tsx b/front/src/components/navbar.tsx
index b10700ed..331df71f 100644
--- a/front/src/components/navbar.tsx
+++ b/front/src/components/navbar.tsx
@@ -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";
diff --git a/front/src/models/resources/episode.ts b/front/src/models/resources/episode.ts
index e36fa67c..27c91985 100644
--- a/front/src/models/resources/episode.ts
+++ b/front/src/models/resources/episode.ts
@@ -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;
},
diff --git a/front/src/models/resources/show.ts b/front/src/models/resources/show.ts
index fbd94416..4a6cf6c3 100644
--- a/front/src/models/resources/show.ts
+++ b/front/src/models/resources/show.ts
@@ -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 = [];
diff --git a/front/src/models/resources/watch-item.ts b/front/src/models/resources/watch-item.ts
new file mode 100644
index 00000000..1d8d5a7a
--- /dev/null
+++ b/front/src/models/resources/watch-item.ts
@@ -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 .
+ */
+
+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;
+
+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;
diff --git a/front/src/pages/_app.tsx b/front/src/pages/_app.tsx
index d0cdc495..f7d6dc96 100755
--- a/front/src/pages/_app.tsx
+++ b/front/src/pages/_app.tsx
@@ -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),
diff --git a/front/src/pages/watch/[slug].tsx b/front/src/pages/watch/[slug].tsx
new file mode 100644
index 00000000..7d4f01de
--- /dev/null
+++ b/front/src/pages/watch/[slug].tsx
@@ -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 .
+ */
+
+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 (
+
+
+
+ );
+};
+
+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(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 (
+ {
+ 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" },
+ },
+ }}
+ >
+
+
+ theme.palette.primary.main,
+ }}
+ />
+ theme.palette.primary.main,
+ }}
+ />
+
+ {chapters?.map((x) => (
+ theme.palette.primary.dark,
+ }}
+ />
+ ))}
+
+
+ );
+};
+
+const VideoPoster = memo(function VideoPoster({ poster }: { poster?: string | null }) {
+ return (
+
+
+
+ );
+});
+
+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 (
+ *": { mx: "8px !important" } }}>
+ {previousSlug && (
+
+
+
+
+
+
+
+ )}
+
+
+ {isPlaying ? : }
+
+
+ {nextSlug && (
+
+
+
+
+
+
+
+ )}
+
+
+
+ {isMuted || volume == 0 ? (
+
+ ) : volume < 25 ? (
+
+ ) : volume < 65 ? (
+
+ ) : (
+
+ )}
+
+
+
+ setVolume(value as number)}
+ size="small"
+ aria-label={t("volume")}
+ sx={{ alignSelf: "center" }}
+ />
+
+
+
+ );
+});
+
+const RightButtons = memo(function RightButton({
+ isFullscreen,
+ toggleFullscreen,
+}: {
+ isFullscreen: boolean;
+ toggleFullscreen: () => void;
+}) {
+ const { t } = useTranslation("player");
+
+ return (
+ *": { mx: "8px !important" } }}>
+
+
+
+
+
+
+
+ {isFullscreen ? : }
+
+
+
+ );
+});
+
+const Back = memo(function Back({ name, href }: { name?: string; href: string }) {
+ const { t } = useTranslation("player");
+
+ return (
+
+
+
+
+
+
+
+
+
+ {name ? name : }
+
+
+ );
+});
+
+const useVideoController = () => {
+ const player = useRef(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 = 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 => ({
+ 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 ;
+
+ return (
+ <>
+
+ {isLoading && }
+ {showHover && (
+ <>
+
+
+
+
+
+ {name ?? }
+
+
+
+
+
+
+
+
+ {toTimerString(progress, duration)} : {toTimerString(duration)}
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+};
+
+Player.getFetchUrls = ({ slug }) => [query(slug)];
+
+export default withRoute(Player);
diff --git a/nginx.conf.template b/nginx.conf.template
index 5a35a11d..7d018741 100644
--- a/nginx.conf.template
+++ b/nginx.conf.template
@@ -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";
}
}