diff --git a/front/package.json b/front/package.json index 4f7c0c9d..60d4b727 100644 --- a/front/package.json +++ b/front/package.json @@ -26,6 +26,7 @@ "@jellyfin/libass-wasm": "^4.1.1", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.8.7", + "jotai": "^1.8.4", "next": "12.2.2", "next-translate": "^1.5.0", "react": "18.2.0", diff --git a/front/src/models/resources/watch-item.ts b/front/src/models/resources/watch-item.ts index 08a8ebe6..023353d1 100644 --- a/front/src/models/resources/watch-item.ts +++ b/front/src/models/resources/watch-item.ts @@ -75,6 +75,10 @@ export const FontP = z.object({ * The format of this font (the extension). */ format: z.string(), + /* + * The url of the font. + */ + link: z.string(), }); export type Font = z.infer; @@ -104,6 +108,10 @@ const WatchMovieP = z.preprocess( x.link = { direct: `/api/video/${x.slug}`, }; + x.fonts = x.fonts?.map((y: Font) => { + y.link = `/api/watch/${x.slug}/font/${y.slug}.${y.format}`; + return y; + }) return x; }, ImagesP.extend({ diff --git a/front/src/pages/browse/index.tsx b/front/src/pages/browse/index.tsx index 573c1415..8c0211c0 100644 --- a/front/src/pages/browse/index.tsx +++ b/front/src/pages/browse/index.tsx @@ -318,9 +318,8 @@ const BrowseSettings = ({ const [sortAnchor, setSortAnchor] = useState(null); const { t } = useTranslation("browse"); - const switchViewTitle = layout === Layout.Grid - ? t("browse.switchToList") - : t("browse.switchToGrid"); + const switchViewTitle = + layout === Layout.Grid ? t("browse.switchToList") : t("browse.switchToGrid"); return ( <> diff --git a/front/src/pages/watch/[slug].tsx b/front/src/pages/watch/[slug].tsx index 846f4bc4..58c21202 100644 --- a/front/src/pages/watch/[slug].tsx +++ b/front/src/pages/watch/[slug].tsx @@ -18,774 +18,6 @@ * along with Kyoo. If not, see . */ -import { QueryIdentifier, QueryPage } from "~/utils/query"; -import { withRoute } from "~/utils/router"; -import { WatchItem, WatchItemP, Chapter, Track, Font } from "~/models/resources/watch-item"; -import { useFetch } from "~/utils/query"; -import { ErrorPage } from "~/components/errors"; -import { useState, useRef, useEffect, memo, useMemo, useCallback, RefObject } from "react"; -import { - Box, - CircularProgress, - IconButton, - Tooltip, - Typography, - Skeleton, - Slider, - Menu, - MenuItem, - ListItemText, - BoxProps, -} 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 { Link } from "~/utils/link"; -import NextLink from "next/link"; -import { useRouter } from "next/router"; -// @ts-ignore -import SubtitleOctopus from "@jellyfin/libass-wasm"; +import Player from "~/player/player"; -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 SubtitleMenu = ({ - subtitles, - setSubtitle, - selectedID, - anchor, - onClose, -}: { - subtitles: Track[]; - setSubtitle: (subtitle: Track | null) => void; - selectedID?: number; - anchor: HTMLElement; - onClose: () => void; -}) => { - const router = useRouter(); - const { t } = useTranslation("player"); - const { subtitle, ...queryWithoutSubs } = router.query; - - return ( - - { - setSubtitle(null); - onClose(); - }} - component={Link} - to={{ query: queryWithoutSubs }} - shallow - replace - > - {t("subtitle-none")} - - {subtitles.map((sub) => ( - { - setSubtitle(sub); - onClose(); - }} - component={Link} - to={{ query: { ...router.query, subtitle: sub.language ?? sub.id } }} - shallow - replace - > - {sub.displayName} - - ))} - - ); -}; - -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"); - const router = useRouter(); - - 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, - subtitles, - selectedSubtitle, - selectSubtitle, -}: { - isFullscreen: boolean; - toggleFullscreen: () => void; - subtitles?: Track[]; - selectedSubtitle: Track | null; - selectSubtitle: (track: Track | null) => void; -}) { - const { t } = useTranslation("player"); - const [subtitleAnchor, setSubtitleAnchor] = useState(null); - - return ( - *": { m: "8px !important" } }}> - {subtitles && ( - - setSubtitleAnchor(event.currentTarget)} - sx={{ color: "white" }} - > - - - - )} - - - {isFullscreen ? : } - - - {subtitleAnchor && ( - setSubtitleAnchor(null)} - /> - )} - - ); -}); - -const Back = memo(function Back({ name, href }: { name?: string; href: string }) { - const { t } = useTranslation("player"); - - return ( - - - - - - - - - - {name ? name : } - - - ); -}); - -const useSubtitleController = ( - player: RefObject, - slug: string, - fonts?: Font[], - subtitles?: Track[], -): [Track | null, (value: Track | null) => void] => { - const [selectedSubtitle, setSubtitle] = useState(null); - const [htmlTrack, setHtmlTrack] = useState(null); - const [subocto, setSubOcto] = useState(null); - const { query: { subtitle } } = useRouter(); - - const selectSubtitle = useCallback( - (value: Track | null) => { - const removeHtmlSubtitle = () => { - if (htmlTrack) htmlTrack.remove(); - setHtmlTrack(null); - }; - const removeOctoSub = () => { - if (subocto) { - subocto.freeTrack(); - subocto.dispose(); - } - setSubOcto(null); - }; - - if (!player.current) return; - - setSubtitle(value); - if (!value) { - removeHtmlSubtitle(); - removeOctoSub(); - } else if (value.codec === "vtt" || value.codec === "srt") { - removeOctoSub(); - const track: HTMLTrackElement = htmlTrack ?? document.createElement("track"); - track.kind = "subtitles"; - track.label = value.displayName; - if (value.language) track.srclang = value.language; - track.src = `subtitle/${value.slug}.vtt`; - track.className = "subtitle_container"; - track.default = true; - track.onload = () => { - if (player.current) player.current.textTracks[0].mode = "showing"; - }; - player.current.appendChild(track); - setHtmlTrack(track); - } else if (value.codec === "ass") { - removeHtmlSubtitle(); - removeOctoSub(); - setSubOcto( - new SubtitleOctopus({ - video: player.current, - subUrl: `/api/subtitle/${value.slug}`, - workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js", - legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js", - fonts: fonts?.map((x) => `/api/watch/${slug}/font/${x.slug}.${x.format}`), - renderMode: "wasm-blend", - }), - ); - } - }, - [htmlTrack, subocto, player, fonts, slug], - ); - - const newSub = subtitles?.find(x => x.language === subtitle); - useEffect(() => { - if (newSub === undefined) return; - console.log("old", selectedSubtitle) - console.log("new", newSub) - if (newSub?.id !== selectedSubtitle?.id) selectSubtitle(newSub); - }, [player.current?.src, newSub, selectedSubtitle, selectSubtitle]); - - return [selectedSubtitle, selectSubtitle]; -}; - -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) return; - if (player.current.paused) player.current.play(); - setPlay(!player.current.paused); - }, []); - - 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: BoxProps<"video"> = 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 { - playerRef: player, - 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, -}); - -// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout -// if the mouse moves again -let mouseCallback: NodeJS.Timeout; - -const Player: QueryPage<{ slug: string }> = ({ slug }) => { - const { data, error } = useFetch(query(slug)); - const { - playerRef, - state: { isPlaying, isLoading, progress, duration, buffered, volume, isMuted, isFullscreen }, - videoProps, - togglePlay, - toggleMute, - toggleFullscreen, - setProgress, - setVolume, - } = useVideoController(); - const [selectedSubtitle, selectSubtitle] = useSubtitleController( - playerRef, - slug, - data?.fonts, - data?.subtitles, - ); - const [showHover, setHover] = useState(false); - const [mouseMoved, setMouseMoved] = useState(false); - - useEffect(() => { - const handler = () => { - setMouseMoved(true); - if (mouseCallback) clearTimeout(mouseCallback); - mouseCallback = setTimeout(() => { - setMouseMoved(false); - }, 2500); - }; - - document.addEventListener("mousemove", handler); - return () => document.removeEventListener("mousemove", handler); - }); - - const name = data - ? data.isMovie - ? data.name - : `${episodeDisplayNumber(data, "")} ${data.name}` - : undefined; - const displayControls = showHover || !isPlaying || mouseMoved; - - if (error) return ; - - return ( - setMouseMoved(false)} - sx={{ cursor: displayControls ? "unset" : "none" }} - > - - {isLoading && } - setHover(true)} - onMouseLeave={() => setHover(false)} - sx={ - displayControls - ? { - visibility: "visible", - opacity: 1, - transition: "opacity .2s ease-in", - } - : { - visibility: "hidden", - opacity: 0, - transition: "opacity .4s ease-out, visibility 0s .4s", - } - } - > - - - - - - {name ?? } - - - - - - - - - {toTimerString(progress, duration)} : {toTimerString(duration)} - - - - - - - - - ); -}; - -Player.getFetchUrls = ({ slug }) => [query(slug)]; - -export default withRoute(Player); +export default Player; diff --git a/front/src/player/components/hover.tsx b/front/src/player/components/hover.tsx new file mode 100644 index 00000000..40d3f530 --- /dev/null +++ b/front/src/player/components/hover.tsx @@ -0,0 +1,149 @@ +/* + * 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 { ArrowBack } from "@mui/icons-material"; +import { + Box, + BoxProps, + CircularProgress, + IconButton, + Skeleton, + Tooltip, + Typography, +} from "@mui/material"; +import useTranslation from "next-translate/useTranslation"; +import NextLink from "next/link"; +import { Poster } from "~/components/poster"; +import { WatchItem } from "~/models/resources/watch-item"; +import { loadAtom } from "../state"; +import { episodeDisplayNumber } from "~/components/episode"; +import { LeftButtons } from "./left-buttons"; +import { RightButtons } from "./right-buttons"; +import { ProgressBar } from "./progress-bar"; +import { useAtomValue } from "jotai"; + +export const Hover = ({ data, ...props }: { data?: WatchItem } & BoxProps) => { + const name = data + ? data.isMovie + ? data.name + : `${episodeDisplayNumber(data, "")} ${data.name}` + : undefined; + + return ( + + + + + + + {name ?? } + + + + + + + + + + + + ); +}; +export const Back = ({ name, href }: { name?: string; href: string }) => { + const { t } = useTranslation("player"); + + return ( + + + + + + + + + + {name ? name : } + + + ); +}; + +const VideoPoster = ({ poster }: { poster?: string | null }) => { + return ( + + + + ); +}; + +export const LoadingIndicator = () => { + const isLoading = useAtomValue(loadAtom); + if (!isLoading) return null; + return ( + + + + ); +}; diff --git a/front/src/player/components/left-buttons.tsx b/front/src/player/components/left-buttons.tsx new file mode 100644 index 00000000..62d1f96a --- /dev/null +++ b/front/src/player/components/left-buttons.tsx @@ -0,0 +1,136 @@ +/* + * 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 { Box, IconButton, Slider, Tooltip, Typography } from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; +import NextLink from "next/link"; +import { Pause, PlayArrow, SkipNext, SkipPrevious, VolumeDown, VolumeMute, VolumeOff, VolumeUp } from "@mui/icons-material"; + +export const LeftButtons = ({ previousSlug, nextSlug }: { previousSlug?: string; nextSlug?: string }) => { + const { t } = useTranslation("player"); + const router = useRouter(); + const [isPlaying, setPlay] = useAtom(playAtom); + + return ( + *": { mx: "8px !important" } }}> + {previousSlug && ( + + + + + + + + )} + + setPlay(!isPlaying)} + aria-label={isPlaying ? t("pause") : t("play")} + sx={{ color: "white" }} + > + {isPlaying ? : } + + + {nextSlug && ( + + + + + + + + )} + + + + ); +}; + +const VolumeSlider = () => { + const [volume, setVolume] = useAtom(volumeAtom); + const [isMuted, setMuted] = useAtom(mutedAtom); + const { t } = useTranslation("player"); + + return ( + + + setMuted(!isMuted)} + aria-label={t("mute")} + sx={{ color: "white" }} + > + {isMuted || volume == 0 ? ( + + ) : volume < 25 ? ( + + ) : volume < 65 ? ( + + ) : ( + + )} + + + + setVolume(value as number)} + size="small" + aria-label={t("volume")} + sx={{ alignSelf: "center" }} + /> + + + ); +}; + +const ProgressText = () => { + const progress = useAtomValue(progressAtom); + const duration = useAtomValue(durationAtom); + + return ( + + {toTimerString(progress, duration)} : {toTimerString(duration)} + + ); +}; + +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); +}; diff --git a/front/src/player/components/progress-bar.tsx b/front/src/player/components/progress-bar.tsx new file mode 100644 index 00000000..c3f9382b --- /dev/null +++ b/front/src/player/components/progress-bar.tsx @@ -0,0 +1,133 @@ +/* + * 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 { Box } from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { Chapter } from "~/models/resources/watch-item"; +import { bufferedAtom, durationAtom, progressAtom } from "../state"; + +export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { + const ref = useRef(null); + const [isSeeking, setSeek] = useState(false); + const [progress, setProgress] = useAtom(progressAtom); + const buffered = useAtomValue(bufferedAtom); + const duration = useAtomValue(durationAtom); + + 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, + }} + /> + ))} + + + ); +}; + diff --git a/front/src/player/components/right-buttons.tsx b/front/src/player/components/right-buttons.tsx new file mode 100644 index 00000000..fb399440 --- /dev/null +++ b/front/src/player/components/right-buttons.tsx @@ -0,0 +1,146 @@ +/* + * 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 { ClosedCaption, Fullscreen, FullscreenExit } from "@mui/icons-material"; +import { Box, IconButton, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material"; +import { useAtom } from "jotai"; +import useTranslation from "next-translate/useTranslation"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { Font, Track } from "~/models/resources/watch-item"; +import { Link } from "~/utils/link"; +import { fullscreenAtom, subtitleAtom } from "../state"; + +export const RightButtons = ({ + subtitles, + fonts, +}: { + subtitles?: Track[]; + fonts?: Font[]; +}) => { + const { t } = useTranslation("player"); + const [subtitleAnchor, setSubtitleAnchor] = useState(null); + const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); + + return ( + *": { m: "8px !important" } }}> + {subtitles && ( + + setSubtitleAnchor(event.currentTarget)} + sx={{ color: "white" }} + > + + + + )} + + setFullscreen(!isFullscreen)} + aria-label={t("fullscreen")} + sx={{ color: "white" }} + > + {isFullscreen ? : } + + + {subtitleAnchor && ( + setSubtitleAnchor(null)} + /> + )} + + ); +}; + +const SubtitleMenu = ({ + subtitles, + fonts, + anchor, + onClose, +}: { + subtitles: Track[]; + fonts: Font[], + anchor: HTMLElement; + onClose: () => void; +}) => { + const router = useRouter(); + const { t } = useTranslation("player"); + const [selectedSubtitle, setSubtitle] = useAtom(subtitleAtom); + const { subtitle, ...queryWithoutSubs } = router.query; + + return ( + + { + setSubtitle(null); + onClose(); + }} + component={Link} + to={{ query: queryWithoutSubs }} + shallow + replace + > + {t("subtitle-none")} + + {subtitles.map((sub) => ( + { + setSubtitle({track: sub, fonts}); + onClose(); + }} + component={Link} + to={{ query: { ...router.query, subtitle: sub.language ?? sub.id } }} + shallow + replace + > + {sub.displayName} + + ))} + + ); +}; + diff --git a/front/src/player/player.tsx b/front/src/player/player.tsx new file mode 100644 index 00000000..3905d81c --- /dev/null +++ b/front/src/player/player.tsx @@ -0,0 +1,113 @@ +/* + * 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 } from "~/models/resources/watch-item"; +import { useFetch } from "~/utils/query"; +import { ErrorPage } from "~/components/errors"; +import { useState, useEffect } from "react"; +import { Box } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { Hover, LoadingIndicator } from "./components/hover"; +import { playAtom, useSubtitleController, useVideoController } from "./state"; + +// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout +// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move) +let mouseCallback: NodeJS.Timeout; + +const query = (slug: string): QueryIdentifier => ({ + path: ["watch", slug], + parser: WatchItemP, +}); + +const Player: QueryPage<{ slug: string }> = ({ slug }) => { + const { data, error } = useFetch(query(slug)); + const { playerRef, videoProps } = useVideoController(); + + const isPlaying = useAtomValue(playAtom); + const [showHover, setHover] = useState(false); + const [mouseMoved, setMouseMoved] = useState(false); + const displayControls = showHover || !isPlaying || mouseMoved; + + useEffect(() => { + const handler = () => { + setMouseMoved(true); + if (mouseCallback) clearTimeout(mouseCallback); + mouseCallback = setTimeout(() => { + setMouseMoved(false); + }, 2500); + }; + + document.addEventListener("mousemove", handler); + return () => document.removeEventListener("mousemove", handler); + }); + + useSubtitleController(playerRef, data?.subtitles, data?.fonts); + + if (error) return ; + + return ( + setMouseMoved(false)} + sx={{ cursor: displayControls ? "unset" : "none" }} + > + + + setHover(true)} + onMouseLeave={() => setHover(false)} + sx={ + displayControls + ? { + visibility: "visible", + opacity: 1, + transition: "opacity .2s ease-in", + } + : { + visibility: "hidden", + opacity: 0, + transition: "opacity .4s ease-out, visibility 0s .4s", + } + } + /> + + ); +}; + +Player.getFetchUrls = ({ slug }) => [query(slug)]; + +export default withRoute(Player); diff --git a/front/src/player/state.tsx b/front/src/player/state.tsx new file mode 100644 index 00000000..9db11d02 --- /dev/null +++ b/front/src/player/state.tsx @@ -0,0 +1,213 @@ +/* + * 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 { BoxProps } from "@mui/material"; +import { atom, useSetAtom } from "jotai"; +import { useRouter } from "next/router"; +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { Font, Track } from "~/models/resources/watch-item"; +import { bakedAtom } from "~/utils/jotai-utils"; +// @ts-ignore +import SubtitleOctopus from "@jellyfin/libass-wasm/dist/js/subtitles-octopus"; + +export const playerAtom = atom | null>(null); +export const [_playAtom, playAtom] = bakedAtom(true, (get, _, value) => { + const player = get(playerAtom); + if (!player?.current) return; + if (value) { + player.current.play(); + } else { + player.current.pause(); + } +}); +export const loadAtom = atom(false); +export const [_progressAtom, progressAtom] = bakedAtom(0, (get, set, value, baker) => { + const player = get(playerAtom); + if (!player?.current) return; + set(baker, value); + player.current.currentTime = value; +}); +export const bufferedAtom = atom(0); +export const durationAtom = atom(1); +export const [_volumeAtom, volumeAtom] = bakedAtom(100, (get, set, value, baker) => { + const player = get(playerAtom); + if (!player?.current) return; + set(baker, value); + if (player.current) player.current.volume = value / 100; +}); +export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker) => { + const player = get(playerAtom); + if (!player?.current) return; + set(baker, value); + if (player.current) player.current.muted = value; +}); +export const [_, fullscreenAtom] = bakedAtom(false, (_, set, value, baker) => { + set(baker, value); + if (value) { + document.body.requestFullscreen(); + } else { + document.exitFullscreen(); + } +}); + +export const useVideoController = () => { + const player = useRef(null); + const setPlayer = useSetAtom(playerAtom); + const setPlay = useSetAtom(_playAtom); + const setLoad = useSetAtom(loadAtom); + const setProgress = useSetAtom(_progressAtom); + const setBuffered = useSetAtom(bufferedAtom); + const setDuration = useSetAtom(durationAtom); + const setVolume = useSetAtom(_volumeAtom); + const setMuted = useSetAtom(_mutedAtom); + const setFullscreen = useSetAtom(fullscreenAtom); + + setPlayer(player); + + useEffect(() => { + if (!player.current) return; + if (player.current.paused) player.current.play(); + setPlay(!player.current.paused); + }, [setPlay]); + + useEffect(() => { + if (!player?.current?.duration) return; + setDuration(player.current.duration); + }, [player, setDuration]); + + const videoProps: BoxProps<"video"> = { + ref: player, + onClick: () => { + if (!player.current) return; + if (player.current.paused) { + player.current.play(); + } else { + player.current.pause(); + } + }, + onDoubleClick: () => { + if (document.fullscreenElement) { + setFullscreen(false); + } else { + setFullscreen(true); + } + }, + 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); + setMuted(player?.current.muted); + }, + autoPlay: true, + controls: false, + }; + return { + playerRef: player, + videoProps, + }; +}; + +const htmlTrackAtom = atom(null); +const suboctoAtom = atom(null); +export const [_subtitleAtom, subtitleAtom] = bakedAtom( + null, + (get, set, value, baked) => { + const removeHtmlSubtitle = () => { + const htmlTrack = get(htmlTrackAtom); + if (htmlTrack) htmlTrack.remove(); + set(htmlTrackAtom, null); + }; + const removeOctoSub = () => { + const subocto = get(suboctoAtom); + if (subocto) { + subocto.freeTrack(); + subocto.dispose(); + } + set(suboctoAtom, null); + }; + + const player = get(playerAtom); + if (!player?.current) return; + + if (get(baked)?.id === value?.track.id) return; + + set(baked, value?.track ?? null); + if (!value) { + removeHtmlSubtitle(); + removeOctoSub(); + } else if (value.track.codec === "vtt" || value.track.codec === "srt") { + removeOctoSub(); + const track: HTMLTrackElement = get(htmlTrackAtom) ?? document.createElement("track"); + track.kind = "subtitles"; + track.label = value.track.displayName; + if (value.track.language) track.srclang = value.track.language; + track.src = `subtitle/${value.track.slug}.vtt`; + track.className = "subtitle_container"; + track.default = true; + track.onload = () => { + if (player.current) player.current.textTracks[0].mode = "showing"; + }; + player.current.appendChild(track); + set(htmlTrackAtom, track); + } else if (value.track.codec === "ass") { + removeHtmlSubtitle(); + removeOctoSub(); + set( + suboctoAtom, + new SubtitleOctopus({ + video: player.current, + subUrl: `/api/subtitle/${value.track.slug}`, + workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js", + legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js", + fonts: value.fonts?.map((x) => x.link), + renderMode: "wasm-blend", + }), + ); + } + }, +); + +export const useSubtitleController = ( + player: RefObject, + subtitles?: Track[], + fonts?: Font[], +) => { + const { + query: { subtitle }, + } = useRouter(); + const selectSubtitle = useSetAtom(subtitleAtom); + + const newSub = subtitles?.find((x) => x.language === subtitle); + useEffect(() => { + if (newSub === undefined) return; + selectSubtitle({track: newSub, fonts: fonts ?? []}); + }, [player.current?.src, newSub, fonts, selectSubtitle]); +}; diff --git a/front/src/utils/jotai-utils.tsx b/front/src/utils/jotai-utils.tsx new file mode 100644 index 00000000..f2254add --- /dev/null +++ b/front/src/utils/jotai-utils.tsx @@ -0,0 +1,35 @@ +/* + * 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 { atom, PrimitiveAtom, WritableAtom } from "jotai"; + +type AtomWritter = Parameters>[1]; +export const bakedAtom = ( + initialValue: T, + writter: (...args: [...Parameters>, PrimitiveAtom]) => void, +): [PrimitiveAtom, WritableAtom] => { + const baker = atom(initialValue); + const pub = atom( + (get) => get(baker), + (...args) => writter(...args, baker), + ); + + return [baker, pub]; +}; diff --git a/front/yarn.lock b/front/yarn.lock index 5649540a..deda6b7d 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -1621,6 +1621,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +jotai@^1.8.4: + version "1.8.4" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.8.4.tgz#e188bff3cc790c758d25646f6f5daf9854c98eef" + integrity sha512-bkHDHNxm7bU4+bJL4z96fTlJYN34UDRTu3ghEajJrDepayON9YEaxPrXr7xhLnIRntoFC6eDYYhMNA/ilbj2RQ== + js-sha3@0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"