Support mobile devices in the player

This commit is contained in:
Zoe Roux 2022-10-10 01:33:46 +09:00
parent 6e4f5feb7c
commit 6534f3bd25
9 changed files with 274 additions and 146 deletions

View File

@ -28,6 +28,7 @@ import { createQueryClient, fetchQuery, QueryIdentifier, QueryPage } from "~/uti
import { defaultTheme } from "~/utils/themes/default-theme"; import { defaultTheme } from "~/utils/themes/default-theme";
import superjson from "superjson"; import superjson from "superjson";
import Head from "next/head"; 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) // Simply silence a SSR warning (see https://github.com/facebook/react/issues/14927 for more details)
if (typeof window === "undefined") { if (typeof window === "undefined") {
@ -39,6 +40,8 @@ const App = ({ Component, pageProps }: AppProps) => {
const { queryState, ...props } = superjson.deserialize<any>(pageProps ?? {}); const { queryState, ...props } = superjson.deserialize<any>(pageProps ?? {});
const getLayout = (Component as QueryPage).getLayout ?? ((page) => page); const getLayout = (Component as QueryPage).getLayout ?? ((page) => page);
useMobileHover();
return ( return (
<> <>
<style jsx global>{` <style jsx global>{`

View File

@ -30,7 +30,7 @@ const Document = () => {
<link rel="icon" type="image/png" sizes="128x128" href="/icon-128x128.png" /> <link rel="icon" type="image/png" sizes="128x128" href="/icon-128x128.png" />
<link rel="icon" type="image/png" sizes="256x256" href="/icon-256x256.png" /> <link rel="icon" type="image/png" sizes="256x256" href="/icon-256x256.png" />
</Head> </Head>
<body> <body className="hoverEnabled">
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>

View File

@ -39,7 +39,12 @@ import { RightButtons } from "./right-buttons";
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import { useAtomValue } from "jotai"; 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 const name = data
? data.isMovie ? data.isMovie
? data.name ? data.name
@ -64,7 +69,7 @@ export const Hover = ({ data, ...props }: { data?: WatchItem } & BoxProps) => {
}} }}
> >
<VideoPoster poster={data?.poster} /> <VideoPoster poster={data?.poster} />
<Box sx={{ width: "100%", ml: 3, display: "flex", flexDirection: "column" }}> <Box sx={{ width: "100%", ml: { xs: 0.5, sm: 3 }, display: "flex", flexDirection: "column" }}>
<Typography variant="h4" component="h2" color="white" sx={{ pb: 1 }}> <Typography variant="h4" component="h2" color="white" sx={{ pb: 1 }}>
{name ?? <Skeleton />} {name ?? <Skeleton />}
</Typography> </Typography>
@ -76,7 +81,7 @@ export const Hover = ({ data, ...props }: { data?: WatchItem } & BoxProps) => {
previousSlug={data && !data.isMovie ? data.previousEpisode?.slug : undefined} previousSlug={data && !data.isMovie ? data.previousEpisode?.slug : undefined}
nextSlug={data && !data.isMovie ? data.nextEpisode?.slug : undefined} nextSlug={data && !data.isMovie ? data.nextEpisode?.slug : undefined}
/> />
<RightButtons subtitles={data?.subtitles} fonts={data?.fonts} /> <RightButtons subtitles={data?.subtitles} fonts={data?.fonts} onMenuOpen={onMenuOpen} onMenuClose={onMenuClose} />
</Box> </Box>
</Box> </Box>
</Box> </Box>

View File

@ -24,15 +24,38 @@ import useTranslation from "next-translate/useTranslation";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
import NextLink from "next/link"; 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 { t } = useTranslation("player");
const router = useRouter(); const router = useRouter();
const [isPlaying, setPlay] = useAtom(playAtom); const [isPlaying, setPlay] = useAtom(playAtom);
return ( return (
<Box sx={{ display: "flex", "> *": { mx: "8px !important" } }}> <Box
sx={{
display: "flex",
"> *": {
mx: { xs: "2px !important", sm: "8px !important" },
p: { xs: "4px !important", sm: "8px !important" },
},
}}
>
{previousSlug && ( {previousSlug && (
<Tooltip title={t("previous")}> <Tooltip title={t("previous")}>
<NextLink href={{ query: { ...router.query, slug: previousSlug } }} passHref> <NextLink href={{ query: { ...router.query, slug: previousSlug } }} passHref>
@ -74,10 +97,10 @@ const VolumeSlider = () => {
return ( return (
<Box <Box
sx={{ sx={{
display: "flex", display: { xs: "none", sm: "flex" },
m: "0 !important", m: "0 !important",
p: "8px", p: "8px",
"&:hover .slider": { width: "100px", px: "16px" }, "body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" },
}} }}
> >
<Tooltip title={t("mute")}> <Tooltip title={t("mute")}>

View File

@ -31,9 +31,10 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
const buffered = useAtomValue(bufferedAtom); const buffered = useAtomValue(bufferedAtom);
const duration = useAtomValue(durationAtom); const duration = useAtomValue(durationAtom);
const updateProgress = (event: MouseEvent, skipSeek?: boolean) => { const updateProgress = (event: MouseEvent | TouchEvent, skipSeek?: boolean) => {
if (!(isSeeking || skipSeek) || !ref?.current) return; 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); setProgress(Math.max(0, Math.min(value, 1)) * duration);
}; };
@ -41,16 +42,25 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
const handler = () => setSeek(false); const handler = () => setSeek(false);
document.addEventListener("mouseup", handler); document.addEventListener("mouseup", handler);
return () => document.removeEventListener("mouseup", handler); document.addEventListener("touchend", handler);
return () => {
document.removeEventListener("mouseup", handler);
document.removeEventListener("touchend", handler);
};
}); });
useEffect(() => { useEffect(() => {
document.addEventListener("mousemove", updateProgress); document.addEventListener("mousemove", updateProgress);
return () => document.removeEventListener("mousemove", updateProgress); document.addEventListener("touchmove", updateProgress);
return () => {
document.removeEventListener("mousemove", updateProgress);
document.removeEventListener("touchmove", updateProgress);
};
}); });
return ( return (
<Box <Box
onMouseDown={(event) => { onMouseDown={(event) => {
// prevent drag and drop of the UI.
event.preventDefault(); event.preventDefault();
setSeek(true); setSeek(true);
}} }}
@ -60,7 +70,8 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
width: "100%", width: "100%",
py: 1, py: 1,
cursor: "pointer", cursor: "pointer",
"&:hover": { WebkitTapHighlightColor: "transparent",
"body.hoverEnabled &:hover": {
".thumb": { opacity: 1 }, ".thumb": { opacity: 1 },
".bar": { transform: "unset" }, ".bar": { transform: "unset" },
}, },
@ -130,4 +141,3 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
</Box> </Box>
); );
}; };

View File

@ -31,16 +31,28 @@ import { fullscreenAtom, subtitleAtom } from "../state";
export const RightButtons = ({ export const RightButtons = ({
subtitles, subtitles,
fonts, fonts,
onMenuOpen,
onMenuClose,
}: { }: {
subtitles?: Track[]; subtitles?: Track[];
fonts?: Font[]; fonts?: Font[];
onMenuOpen: () => void;
onMenuClose: () => void;
}) => { }) => {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null); const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
return ( return (
<Box sx={{ "> *": { m: "8px !important" } }}> <Box
sx={{
display: "flex",
"> *": {
m: { xs: "4px !important", sm: "8px !important" },
p: { xs: "4px !important", sm: "8px !important" },
},
}}
>
{subtitles && ( {subtitles && (
<Tooltip title={t("subtitles")}> <Tooltip title={t("subtitles")}>
<IconButton <IconButton
@ -49,7 +61,10 @@ export const RightButtons = ({
aria-controls={subtitleAnchor ? "subtitle-menu" : undefined} aria-controls={subtitleAnchor ? "subtitle-menu" : undefined}
aria-haspopup="true" aria-haspopup="true"
aria-expanded={subtitleAnchor ? "true" : undefined} aria-expanded={subtitleAnchor ? "true" : undefined}
onClick={(event) => setSubtitleAnchor(event.currentTarget)} onClick={(event) => {
setSubtitleAnchor(event.currentTarget);
onMenuOpen();
}}
sx={{ color: "white" }} sx={{ color: "white" }}
> >
<ClosedCaption /> <ClosedCaption />
@ -70,7 +85,10 @@ export const RightButtons = ({
subtitles={subtitles!} subtitles={subtitles!}
fonts={fonts!} fonts={fonts!}
anchor={subtitleAnchor} anchor={subtitleAnchor}
onClose={() => setSubtitleAnchor(null)} onClose={() => {
setSubtitleAnchor(null);
onMenuClose();
}}
/> />
)} )}
</Box> </Box>
@ -84,7 +102,7 @@ const SubtitleMenu = ({
onClose, onClose,
}: { }: {
subtitles: Track[]; subtitles: Track[];
fonts: Font[], fonts: Font[];
anchor: HTMLElement; anchor: HTMLElement;
onClose: () => void; onClose: () => void;
}) => { }) => {
@ -143,4 +161,3 @@ const SubtitleMenu = ({
</Menu> </Menu>
); );
}; };

View File

@ -23,11 +23,11 @@ import { withRoute } from "~/utils/router";
import { WatchItem, WatchItemP } from "~/models/resources/watch-item"; import { WatchItem, WatchItemP } from "~/models/resources/watch-item";
import { useFetch } from "~/utils/query"; import { useFetch } from "~/utils/query";
import { ErrorPage } from "~/components/errors"; import { ErrorPage } from "~/components/errors";
import { useState, useEffect } from "react"; import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { useAtomValue } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Hover, LoadingIndicator } from "./components/hover"; 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 // 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) // if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
@ -40,31 +40,51 @@ const query = (slug: string): QueryIdentifier<WatchItem> => ({
const Player: QueryPage<{ slug: string }> = ({ slug }) => { const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(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 isPlaying = useAtomValue(playAtom);
const [showHover, setHover] = useState(false); const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false); const [mouseMoved, setMouseMoved] = useState(false);
const displayControls = showHover || !isPlaying || mouseMoved; const [menuOpenned, setMenuOpen] = useState(false);
const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned;
useEffect(() => { const mouseHasMoved = () => {
const handler = () => {
setMouseMoved(true); setMouseMoved(true);
if (mouseCallback) clearTimeout(mouseCallback); if (mouseCallback) clearTimeout(mouseCallback);
mouseCallback = setTimeout(() => { mouseCallback = setTimeout(() => {
setMouseMoved(false); setMouseMoved(false);
}, 2500); }, 2500);
}
useEffect(() => {
const handler = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
mouseHasMoved();
}; };
document.addEventListener("mousemove", handler); document.addEventListener("pointermove", handler);
return () => document.removeEventListener("mousemove", handler); return () => document.removeEventListener("pointermove", handler);
}); });
useSubtitleController(playerRef, data?.subtitles, data?.fonts); useSubtitleController(playerRef, data?.subtitles, data?.fonts);
useEffect(() => {
if (!/Mobi/i.test(window.navigator.userAgent)) return;
setFullscreen(true);
return () => setFullscreen(false);
}, [setFullscreen]);
if (error) return <ErrorPage {...error} />; if (error) return <ErrorPage {...error} />;
return ( return (
<>
<style jsx global>{`
::cue {
background-color: transparent;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
}
`}</style>
<Box <Box
onMouseLeave={() => setMouseMoved(false)} onMouseLeave={() => setMouseMoved(false)}
sx={{ cursor: displayControls ? "unset" : "none" }} sx={{ cursor: displayControls ? "unset" : "none" }}
@ -73,8 +93,17 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
component="video" component="video"
src={data?.link.direct} src={data?.link.direct}
{...videoProps} {...videoProps}
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
if (e.pointerType === "mouse") {
onVideoClick();
} else if (mouseMoved) {
setMouseMoved(false);
} else {
mouseHasMoved();
}
}}
sx={{ sx={{
position: "absolute", position: "fixed",
top: 0, top: 0,
bottom: 0, bottom: 0,
left: 0, left: 0,
@ -88,8 +117,16 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
<LoadingIndicator /> <LoadingIndicator />
<Hover <Hover
data={data} data={data}
onMouseEnter={() => setHover(true)} onPointerOver={(e: ReactPointerEvent<HTMLElement>) => {
onMouseLeave={() => setHover(false)} 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={ sx={
displayControls displayControls
? { ? {
@ -105,6 +142,7 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
} }
/> />
</Box> </Box>
</>
); );
}; };

View File

@ -58,13 +58,17 @@ export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker)
set(baker, value); set(baker, value);
if (player.current) player.current.muted = value; if (player.current) player.current.muted = value;
}); });
export const [_, fullscreenAtom] = bakedAtom(false, (_, set, value, baker) => { export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker) => {
set(baker, value); try {
if (value) { if (value) {
document.body.requestFullscreen(); await document.body.requestFullscreen();
await screen.orientation.lock("landscape");
} else { } else {
document.exitFullscreen(); await document.exitFullscreen();
screen.orientation.unlock();
} }
set(baker, value);
} catch {}
}); });
export const useVideoController = () => { export const useVideoController = () => {
@ -94,14 +98,6 @@ export const useVideoController = () => {
const videoProps: BoxProps<"video"> = { const videoProps: BoxProps<"video"> = {
ref: player, ref: player,
onClick: () => {
if (!player.current) return;
if (player.current.paused) {
player.current.play();
} else {
player.current.pause();
}
},
onDoubleClick: () => { onDoubleClick: () => {
if (document.fullscreenElement) { if (document.fullscreenElement) {
setFullscreen(false); setFullscreen(false);
@ -132,14 +128,23 @@ export const useVideoController = () => {
return { return {
playerRef: player, playerRef: player,
videoProps, videoProps,
onVideoClick: () => {
if (!player.current) return;
if (player.current.paused) {
player.current.play();
} else {
player.current.pause();
}
},
}; };
}; };
const htmlTrackAtom = atom<HTMLTrackElement | null>(null); const htmlTrackAtom = atom<HTMLTrackElement | null>(null);
const suboctoAtom = atom<SubtitleOctopus | null>(null); const suboctoAtom = atom<SubtitleOctopus | null>(null);
export const [_subtitleAtom, subtitleAtom] = bakedAtom<Track | null, { track: Track, fonts: Font[] } | null>( export const [_subtitleAtom, subtitleAtom] = bakedAtom<
null, Track | null,
(get, set, value, baked) => { { track: Track; fonts: Font[] } | null
>(null, (get, set, value, baked) => {
const removeHtmlSubtitle = () => { const removeHtmlSubtitle = () => {
const htmlTrack = get(htmlTrackAtom); const htmlTrack = get(htmlTrackAtom);
if (htmlTrack) htmlTrack.remove(); if (htmlTrack) htmlTrack.remove();
@ -193,8 +198,7 @@ export const [_subtitleAtom, subtitleAtom] = bakedAtom<Track | null, { track: Tr
}), }),
); );
} }
}, });
);
export const useSubtitleController = ( export const useSubtitleController = (
player: RefObject<HTMLVideoElement>, player: RefObject<HTMLVideoElement>,

View File

@ -18,6 +18,34 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { useEffect } from "react";
export const makeTitle = (title?: string) => { export const makeTitle = (title?: string) => {
return title ? `${title} - Kyoo` : "Kyoo"; 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);
};
}, []);
};