diff --git a/front/src/pages/_app.tsx b/front/src/pages/_app.tsx index f7d6dc96..7699b1e6 100755 --- a/front/src/pages/_app.tsx +++ b/front/src/pages/_app.tsx @@ -28,6 +28,7 @@ import { createQueryClient, fetchQuery, QueryIdentifier, QueryPage } from "~/uti import { defaultTheme } from "~/utils/themes/default-theme"; import superjson from "superjson"; import Head from "next/head"; +import { useMobileHover } from "~/utils/utils"; // Simply silence a SSR warning (see https://github.com/facebook/react/issues/14927 for more details) if (typeof window === "undefined") { @@ -38,6 +39,8 @@ const App = ({ Component, pageProps }: AppProps) => { const [queryClient] = useState(() => createQueryClient()); const { queryState, ...props } = superjson.deserialize(pageProps ?? {}); const getLayout = (Component as QueryPage).getLayout ?? ((page) => page); + + useMobileHover(); return ( <> diff --git a/front/src/pages/_document.tsx b/front/src/pages/_document.tsx index 4770da37..d2fd3388 100644 --- a/front/src/pages/_document.tsx +++ b/front/src/pages/_document.tsx @@ -30,7 +30,7 @@ const Document = () => { - +
diff --git a/front/src/player/components/hover.tsx b/front/src/player/components/hover.tsx index 40d3f530..2c8a8590 100644 --- a/front/src/player/components/hover.tsx +++ b/front/src/player/components/hover.tsx @@ -39,7 +39,12 @@ import { RightButtons } from "./right-buttons"; import { ProgressBar } from "./progress-bar"; import { useAtomValue } from "jotai"; -export const Hover = ({ data, ...props }: { data?: WatchItem } & BoxProps) => { +export const Hover = ({ + data, + onMenuOpen, + onMenuClose, + ...props +}: { data?: WatchItem; onMenuOpen: () => void; onMenuClose: () => void } & BoxProps) => { const name = data ? data.isMovie ? data.name @@ -64,7 +69,7 @@ export const Hover = ({ data, ...props }: { data?: WatchItem } & BoxProps) => { }} > - + {name ?? } @@ -76,7 +81,7 @@ export const Hover = ({ data, ...props }: { data?: WatchItem } & BoxProps) => { previousSlug={data && !data.isMovie ? data.previousEpisode?.slug : undefined} nextSlug={data && !data.isMovie ? data.nextEpisode?.slug : undefined} /> - + diff --git a/front/src/player/components/left-buttons.tsx b/front/src/player/components/left-buttons.tsx index 62d1f96a..10ada8d3 100644 --- a/front/src/player/components/left-buttons.tsx +++ b/front/src/player/components/left-buttons.tsx @@ -24,15 +24,38 @@ 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"; +import { + Pause, + PlayArrow, + SkipNext, + SkipPrevious, + VolumeDown, + VolumeMute, + VolumeOff, + VolumeUp, +} from "@mui/icons-material"; -export const LeftButtons = ({ previousSlug, nextSlug }: { previousSlug?: string; nextSlug?: string }) => { +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" } }}> + *": { + mx: { xs: "2px !important", sm: "8px !important" }, + p: { xs: "4px !important", sm: "8px !important" }, + }, + }} + > {previousSlug && ( @@ -74,10 +97,10 @@ const VolumeSlider = () => { return ( diff --git a/front/src/player/components/progress-bar.tsx b/front/src/player/components/progress-bar.tsx index c3f9382b..a3866661 100644 --- a/front/src/player/components/progress-bar.tsx +++ b/front/src/player/components/progress-bar.tsx @@ -31,9 +31,10 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { const buffered = useAtomValue(bufferedAtom); const duration = useAtomValue(durationAtom); - const updateProgress = (event: MouseEvent, skipSeek?: boolean) => { + const updateProgress = (event: MouseEvent | TouchEvent, skipSeek?: boolean) => { if (!(isSeeking || skipSeek) || !ref?.current) return; - const value: number = (event.pageX - ref.current.offsetLeft) / ref.current.clientWidth; + const pageX: number = "pageX" in event ? event.pageX : event.changedTouches[0].pageX; + const value: number = (pageX - ref.current.offsetLeft) / ref.current.clientWidth; setProgress(Math.max(0, Math.min(value, 1)) * duration); }; @@ -41,16 +42,25 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { const handler = () => setSeek(false); document.addEventListener("mouseup", handler); - return () => document.removeEventListener("mouseup", handler); + document.addEventListener("touchend", handler); + return () => { + document.removeEventListener("mouseup", handler); + document.removeEventListener("touchend", handler); + }; }); useEffect(() => { document.addEventListener("mousemove", updateProgress); - return () => document.removeEventListener("mousemove", updateProgress); + document.addEventListener("touchmove", updateProgress); + return () => { + document.removeEventListener("mousemove", updateProgress); + document.removeEventListener("touchmove", updateProgress); + }; }); return ( { + // prevent drag and drop of the UI. event.preventDefault(); setSeek(true); }} @@ -60,7 +70,8 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { width: "100%", py: 1, cursor: "pointer", - "&:hover": { + WebkitTapHighlightColor: "transparent", + "body.hoverEnabled &:hover": { ".thumb": { opacity: 1 }, ".bar": { transform: "unset" }, }, @@ -130,4 +141,3 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { ); }; - diff --git a/front/src/player/components/right-buttons.tsx b/front/src/player/components/right-buttons.tsx index fb399440..f2c93908 100644 --- a/front/src/player/components/right-buttons.tsx +++ b/front/src/player/components/right-buttons.tsx @@ -31,16 +31,28 @@ import { fullscreenAtom, subtitleAtom } from "../state"; export const RightButtons = ({ subtitles, fonts, + onMenuOpen, + onMenuClose, }: { subtitles?: Track[]; fonts?: Font[]; + onMenuOpen: () => void; + onMenuClose: () => void; }) => { const { t } = useTranslation("player"); const [subtitleAnchor, setSubtitleAnchor] = useState(null); const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); return ( - *": { m: "8px !important" } }}> + *": { + m: { xs: "4px !important", sm: "8px !important" }, + p: { xs: "4px !important", sm: "8px !important" }, + }, + }} + > {subtitles && ( setSubtitleAnchor(event.currentTarget)} + onClick={(event) => { + setSubtitleAnchor(event.currentTarget); + onMenuOpen(); + }} sx={{ color: "white" }} > @@ -70,7 +85,10 @@ export const RightButtons = ({ subtitles={subtitles!} fonts={fonts!} anchor={subtitleAnchor} - onClose={() => setSubtitleAnchor(null)} + onClose={() => { + setSubtitleAnchor(null); + onMenuClose(); + }} /> )} @@ -84,7 +102,7 @@ const SubtitleMenu = ({ onClose, }: { subtitles: Track[]; - fonts: Font[], + fonts: Font[]; anchor: HTMLElement; onClose: () => void; }) => { @@ -129,7 +147,7 @@ const SubtitleMenu = ({ key={sub.id} selected={selectedSubtitle?.id === sub.id} onClick={() => { - setSubtitle({track: sub, fonts}); + setSubtitle({ track: sub, fonts }); onClose(); }} component={Link} @@ -143,4 +161,3 @@ const SubtitleMenu = ({ ); }; - diff --git a/front/src/player/player.tsx b/front/src/player/player.tsx index 3905d81c..5b372049 100644 --- a/front/src/player/player.tsx +++ b/front/src/player/player.tsx @@ -23,11 +23,11 @@ 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 { useState, useEffect, PointerEvent as ReactPointerEvent } from "react"; import { Box } from "@mui/material"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { Hover, LoadingIndicator } from "./components/hover"; -import { playAtom, useSubtitleController, useVideoController } from "./state"; +import { fullscreenAtom, 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) @@ -40,71 +40,109 @@ const query = (slug: string): QueryIdentifier => ({ const Player: QueryPage<{ slug: string }> = ({ slug }) => { const { data, error } = useFetch(query(slug)); - const { playerRef, videoProps } = useVideoController(); + const { playerRef, videoProps, onVideoClick } = useVideoController(); + const setFullscreen = useSetAtom(fullscreenAtom); const isPlaying = useAtomValue(playAtom); const [showHover, setHover] = useState(false); const [mouseMoved, setMouseMoved] = useState(false); - const displayControls = showHover || !isPlaying || mouseMoved; + const [menuOpenned, setMenuOpen] = useState(false); + const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned; + + const mouseHasMoved = () => { + setMouseMoved(true); + if (mouseCallback) clearTimeout(mouseCallback); + mouseCallback = setTimeout(() => { + setMouseMoved(false); + }, 2500); + } useEffect(() => { - const handler = () => { - setMouseMoved(true); - if (mouseCallback) clearTimeout(mouseCallback); - mouseCallback = setTimeout(() => { - setMouseMoved(false); - }, 2500); + const handler = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + mouseHasMoved(); }; - document.addEventListener("mousemove", handler); - return () => document.removeEventListener("mousemove", handler); + document.addEventListener("pointermove", handler); + return () => document.removeEventListener("pointermove", handler); }); useSubtitleController(playerRef, data?.subtitles, data?.fonts); + useEffect(() => { + if (!/Mobi/i.test(window.navigator.userAgent)) return; + setFullscreen(true); + return () => setFullscreen(false); + }, [setFullscreen]); + 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", - } + <> + + setMouseMoved(false)} + sx={{ cursor: displayControls ? "unset" : "none" }} + > + ) => { + if (e.pointerType === "mouse") { + onVideoClick(); + } else if (mouseMoved) { + setMouseMoved(false); + } else { + mouseHasMoved(); + } + }} + sx={{ + position: "fixed", + top: 0, + bottom: 0, + left: 0, + right: 0, + width: "100%", + height: "100%", + objectFit: "contain", + background: "black", + }} + /> + + ) => { + if (e.pointerType === "mouse") setHover(true); + }} + onPointerOut={() => setHover(false)} + onMenuOpen={() => setMenuOpen(true)} + onMenuClose={() => { + // Disable hover since the menu overlay makes the mouseout unreliable. + setHover(false); + setMenuOpen(false); + }} + sx={ + displayControls + ? { + visibility: "visible", + opacity: 1, + transition: "opacity .2s ease-in", + } + : { + visibility: "hidden", + opacity: 0, + transition: "opacity .4s ease-out, visibility 0s .4s", + } + } + /> + + ); }; diff --git a/front/src/player/state.tsx b/front/src/player/state.tsx index e1499d5e..78535000 100644 --- a/front/src/player/state.tsx +++ b/front/src/player/state.tsx @@ -58,13 +58,17 @@ export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker) 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 [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker) => { + try { + if (value) { + await document.body.requestFullscreen(); + await screen.orientation.lock("landscape"); + } else { + await document.exitFullscreen(); + screen.orientation.unlock(); + } + set(baker, value); + } catch {} }); export const useVideoController = () => { @@ -94,14 +98,6 @@ export const useVideoController = () => { 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); @@ -132,69 +128,77 @@ export const useVideoController = () => { return { playerRef: player, videoProps, + onVideoClick: () => { + if (!player.current) return; + if (player.current.paused) { + player.current.play(); + } else { + player.current.pause(); + } + }, }; }; 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 === "subrip") { - removeOctoSub(); - if (player.current.textTracks.length > 0) player.current.textTracks[0].mode = "hidden"; - 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 = value.track.link! + ".vtt"; - track.className = "subtitle_container"; - track.default = true; - track.onload = () => { - if (player.current) player.current.textTracks[0].mode = "showing"; - }; - if (!get(htmlTrackAtom)) player.current.appendChild(track); - set(htmlTrackAtom, track); - } else if (value.track.codec === "ass") { - removeHtmlSubtitle(); - removeOctoSub(); - set( - suboctoAtom, - new SubtitleOctopus({ - video: player.current, - subUrl: value.track.link!, - 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 [_subtitleAtom, subtitleAtom] = bakedAtom< + Track | null, + { track: Track; fonts: Font[] } | null +>(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 === "subrip") { + removeOctoSub(); + if (player.current.textTracks.length > 0) player.current.textTracks[0].mode = "hidden"; + 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 = value.track.link! + ".vtt"; + track.className = "subtitle_container"; + track.default = true; + track.onload = () => { + if (player.current) player.current.textTracks[0].mode = "showing"; + }; + if (!get(htmlTrackAtom)) player.current.appendChild(track); + set(htmlTrackAtom, track); + } else if (value.track.codec === "ass") { + removeHtmlSubtitle(); + removeOctoSub(); + set( + suboctoAtom, + new SubtitleOctopus({ + video: player.current, + subUrl: value.track.link!, + 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, @@ -209,6 +213,6 @@ export const useSubtitleController = ( const newSub = subtitles?.find((x) => x.language === subtitle); useEffect(() => { if (newSub === undefined) return; - selectSubtitle({track: newSub, fonts: fonts ?? []}); + selectSubtitle({ track: newSub, fonts: fonts ?? [] }); }, [player.current?.src, newSub, fonts, selectSubtitle]); }; diff --git a/front/src/utils/utils.ts b/front/src/utils/utils.ts index f09061f4..c048fbc6 100644 --- a/front/src/utils/utils.ts +++ b/front/src/utils/utils.ts @@ -18,6 +18,34 @@ * along with Kyoo. If not, see . */ +import { useEffect } from "react"; + export const makeTitle = (title?: string) => { return title ? `${title} - Kyoo` : "Kyoo"; }; + +let preventHover: boolean = false; +let hoverTimeout: NodeJS.Timeout; + +export const useMobileHover = () => { + useEffect(() => { + const enableHover = () => { + if (preventHover) return; + document.body.classList.add("hoverEnabled"); + } + + const disableHover = () => { + if (hoverTimeout) clearTimeout(hoverTimeout); + preventHover = true; + hoverTimeout = setTimeout(() => preventHover = false, 500); + document.body.classList.remove("hoverEnabled"); + } + + document.addEventListener("touchstart", disableHover, true); + document.addEventListener("mousemove", enableHover, true); + return () => { + document.removeEventListener("touchstart", disableHover); + document.removeEventListener("mousemove", enableHover); + }; + }, []); +};