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"; } }