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 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<any>(pageProps ?? {});
const getLayout = (Component as QueryPage).getLayout ?? ((page) => page);
useMobileHover();
return (
<>

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="256x256" href="/icon-256x256.png" />
</Head>
<body>
<body className="hoverEnabled">
<Main />
<NextScript />
</body>

View File

@ -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) => {
}}
>
<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 }}>
{name ?? <Skeleton />}
</Typography>
@ -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}
/>
<RightButtons subtitles={data?.subtitles} fonts={data?.fonts} />
<RightButtons subtitles={data?.subtitles} fonts={data?.fonts} onMenuOpen={onMenuOpen} onMenuClose={onMenuClose} />
</Box>
</Box>
</Box>

View File

@ -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 (
<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 && (
<Tooltip title={t("previous")}>
<NextLink href={{ query: { ...router.query, slug: previousSlug } }} passHref>
@ -74,10 +97,10 @@ const VolumeSlider = () => {
return (
<Box
sx={{
display: "flex",
display: { xs: "none", sm: "flex" },
m: "0 !important",
p: "8px",
"&:hover .slider": { width: "100px", px: "16px" },
"body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" },
}}
>
<Tooltip title={t("mute")}>

View File

@ -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 (
<Box
onMouseDown={(event) => {
// 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[] }) => {
</Box>
);
};

View File

@ -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<HTMLButtonElement | null>(null);
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
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 && (
<Tooltip title={t("subtitles")}>
<IconButton
@ -49,7 +61,10 @@ export const RightButtons = ({
aria-controls={subtitleAnchor ? "subtitle-menu" : undefined}
aria-haspopup="true"
aria-expanded={subtitleAnchor ? "true" : undefined}
onClick={(event) => setSubtitleAnchor(event.currentTarget)}
onClick={(event) => {
setSubtitleAnchor(event.currentTarget);
onMenuOpen();
}}
sx={{ color: "white" }}
>
<ClosedCaption />
@ -70,7 +85,10 @@ export const RightButtons = ({
subtitles={subtitles!}
fonts={fonts!}
anchor={subtitleAnchor}
onClose={() => setSubtitleAnchor(null)}
onClose={() => {
setSubtitleAnchor(null);
onMenuClose();
}}
/>
)}
</Box>
@ -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 = ({
</Menu>
);
};

View File

@ -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<WatchItem> => ({
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 <ErrorPage {...error} />;
return (
<Box
onMouseLeave={() => setMouseMoved(false)}
sx={{ cursor: displayControls ? "unset" : "none" }}
>
<Box
component="video"
src={data?.link.direct}
{...videoProps}
sx={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "black",
}}
/>
<LoadingIndicator />
<Hover
data={data}
onMouseEnter={() => 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",
}
<>
<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;
}
/>
</Box>
`}</style>
<Box
onMouseLeave={() => setMouseMoved(false)}
sx={{ cursor: displayControls ? "unset" : "none" }}
>
<Box
component="video"
src={data?.link.direct}
{...videoProps}
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
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",
}}
/>
<LoadingIndicator />
<Hover
data={data}
onPointerOver={(e: ReactPointerEvent<HTMLElement>) => {
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",
}
}
/>
</Box>
</>
);
};

View File

@ -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<HTMLTrackElement | null>(null);
const suboctoAtom = atom<SubtitleOctopus | null>(null);
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 [_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<HTMLVideoElement>,
@ -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]);
};

View File

@ -18,6 +18,34 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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);
};
}, []);
};