diff --git a/front/packages/ui/src/player/components/hover.tsx b/front/packages/ui/src/player/components/hover.tsx index 77fca5c8..abaf940a 100644 --- a/front/packages/ui/src/player/components/hover.tsx +++ b/front/packages/ui/src/player/components/hover.tsx @@ -31,19 +31,43 @@ import { Skeleton, Slider, tooltip, - touchOnly, ts, } from "@kyoo/primitives"; import { Chapter, KyooImage, Subtitle, Audio } from "@kyoo/models"; import { useAtomValue, useSetAtom, useAtom } from "jotai"; -import { ImageStyle, Platform, Pressable, View, ViewProps } from "react-native"; +import { + ImageStyle, + Platform, + Pressable, + View, + ViewProps, + PointerEvent as NativePointerEvent, +} from "react-native"; import { useTranslation } from "react-i18next"; import { percent, rem, useYoshiki } from "yoshiki/native"; import { useRouter } from "solito/router"; import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; import { LeftButtons, TouchControls } from "./left-buttons"; import { RightButtons } from "./right-buttons"; -import { bufferedAtom, durationAtom, loadAtom, playAtom, progressAtom } from "../state"; +import { + bufferedAtom, + durationAtom, + fullscreenAtom, + loadAtom, + playAtom, + progressAtom, +} from "../state"; +import { ReactNode, useCallback, useEffect, useRef } from "react"; +import { atom } from "jotai"; + +const hoverReasonAtom = atom({ + mouseMoved: false, + mouseHover: false, + menuOpened: false, +}); +export const hoverAtom = atom((get) => + [!get(playAtom), ...Object.values(get(hoverReasonAtom))].includes(true), +); export const Hover = ({ isLoading, @@ -57,11 +81,6 @@ export const Hover = ({ fonts, previousSlug, nextSlug, - onMenuOpen, - onMenuClose, - show, - onPointerDown, - ...props }: { isLoading: boolean; name?: string | null; @@ -74,78 +93,207 @@ export const Hover = ({ fonts?: string[]; previousSlug?: string | null; nextSlug?: string | null; - onMenuOpen: () => void; - onMenuClose: () => void; - show: boolean; -} & ViewProps) => { - // TODO: animate show - const opacity = !show && (Platform.OS === "web" ? { opacity: 0 } : { display: "none" as const }); +}) => { + const show = useAtomValue(hoverAtom); + const setHover = useSetAtom(hoverReasonAtom); + return ( {({ css }) => ( <> - - onPointerDown?.({} as any) : undefined} - {...css( - [ - { - // Fixed is used because firefox android make the hover disapear under the navigation bar in absolute - position: Platform.OS === "web" ? ("fixed" as any) : "absolute", - bottom: 0, - left: 0, - right: 0, - bg: (theme) => theme.darkOverlay, - flexDirection: "row", - padding: percent(1), - }, - opacity, - ], - props, - )} + { + if (e.nativeEvent.pointerType === "mouse") + setHover((x) => ({ ...x, mouseHover: true })); + }} + onPointerLeave={(e) => { + if (e.nativeEvent.pointerType === "mouse") + setHover((x) => ({ ...x, mouseHover: false })); + }} + pointerEvents="none" + {...css({ + // TODO: animate show + display: !show ? "none" : "flex", + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + })} > - + theme.darkOverlay, + flexDirection: "row", + padding: percent(1), })} > -

- {isLoading ? : name} -

- + - - +

+ {isLoading ? : name} +

+ + + + setHover((x) => ({ ...x, menuOpened: true }))} + onMenuClose={() => { + // Disable hover since the menu overlay makes the mouseout unreliable. + setHover((x) => ({ ...x, menuOpened: false, mouseHover: false })); + }} + /> +
-
+ )}
); }; +export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => { + const hover = useAtomValue(hoverAtom); + const setHover = useSetAtom(hoverReasonAtom); + const mouseCallback = useRef(null); + const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ count: 0 }); + const playerWidth = useRef(null); + + const show = useCallback(() => { + setHover((x) => ({ ...x, mouseMoved: true })); + if (mouseCallback.current) clearTimeout(mouseCallback.current); + mouseCallback.current = setTimeout(() => { + setHover((x) => ({ ...x, mouseMoved: false })); + }, 2500); + }, [setHover]); + + // On mouse move + useEffect(() => { + if (Platform.OS !== "web") return; + const handler = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + show(); + }; + + document.addEventListener("pointermove", handler); + return () => document.removeEventListener("pointermove", handler); + }, [show]); + + // When the controls hide, remove focus so space can be used to play/pause instead of triggering the button + // It also serves to hide the tooltip. + useEffect(() => { + if (Platform.OS !== "web") return; + if (!hover && document.activeElement instanceof HTMLElement) document.activeElement.blur(); + }, [hover]); + + const { css } = useYoshiki(); + + const duration = useAtomValue(durationAtom); + const setPlay = useSetAtom(playAtom); + const setProgress = useSetAtom(progressAtom); + const setFullscreen = useSetAtom(fullscreenAtom); + + const onPress = (e: NativePointerEvent) => { + if (Platform.OS === "web" && e.nativeEvent.pointerType === "mouse") { + setPlay((x) => !x); + return; + } + if (hover) setHover((x) => ({ ...x, mouseMoved: false })); + else show(); + }; + const onDoublePress = (e: NativePointerEvent) => { + if (Platform.OS === "web" && e.nativeEvent.pointerType === "mouse") { + // Only reset touch count for the web, on mobile you can continue to seek by pressing again. + touch.current.count = 0; + setFullscreen((x) => !x); + return; + } + + if (!duration || !playerWidth.current) return; + + if (e.nativeEvent.x < playerWidth.current * 0.33) { + setProgress((x) => Math.max(x - 10, 0)); + } + if (e.nativeEvent.x > playerWidth.current * 0.66) { + setProgress((x) => Math.min(x + 10, duration)); + } + }; + + return ( + { + if (e.nativeEvent.pointerType === "mouse") setHover((x) => ({ ...x, mouseMoved: false })); + }} + onPointerDown={(e) => { + console.log("down"); + if (Platform.OS === "web") e.preventDefault(); + + touch.current.count++; + if (touch.current.count >= 2) { + touch.current.count = 0; + onDoublePress(e); + clearTimeout(touch.current.timeout); + } else { + onPress(e); + } + + touch.current.timeout = setTimeout(() => { + touch.current.count = 0; + touch.current.timeout = undefined; + }, 400); + }} + onLayout={(e) => { + playerWidth.current = e.nativeEvent.layout.width; + }} + {...css( + { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + // @ts-expect-error Web only property + cursor: hover ? "unset" : "none", + }, + props, + )} + > + {children} + + ); +}; + const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { const [progress, setProgress] = useAtom(progressAtom); const buffered = useAtomValue(bufferedAtom); @@ -165,7 +313,7 @@ const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { ); }; -const Back = ({ +export const Back = ({ isLoading, name, href, diff --git a/front/packages/ui/src/player/components/left-buttons.tsx b/front/packages/ui/src/player/components/left-buttons.tsx index 446fa4ca..ac9db073 100644 --- a/front/packages/ui/src/player/components/left-buttons.tsx +++ b/front/packages/ui/src/player/components/left-buttons.tsx @@ -18,17 +18,7 @@ * along with Kyoo. If not, see . */ -import { - IconButton, - Link, - NoTouch, - P, - Slider, - noTouch, - tooltip, - touchOnly, - ts, -} from "@kyoo/primitives"; +import { IconButton, Link, P, Slider, noTouch, tooltip, touchOnly, ts } from "@kyoo/primitives"; import { useAtom, useAtomValue } from "jotai"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; @@ -38,11 +28,11 @@ import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg"; import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg"; import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg"; -import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg"; +import common from "@material-symbols/svg-400/rounded/volume_down-fill.svg"; import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg"; import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; import { Stylable, px, useYoshiki } from "yoshiki/native"; -import { Component, ComponentProps } from "react"; +import { HoverTouch, hoverAtom } from "./hover"; export const LeftButtons = ({ previousSlug, @@ -96,18 +86,30 @@ export const LeftButtons = ({ export const TouchControls = ({ previousSlug, nextSlug, + ...props }: { previousSlug?: string | null; nextSlug?: string | null; }) => { const { css } = useYoshiki(); - const { t } = useTranslation(); const [isPlaying, setPlay] = useAtom(playAtom); + const hover = useAtomValue(hoverAtom); - const spacing = css({ backgroundColor: (theme) => theme.darkOverlay, marginHorizontal: ts(3) }); + const common = css( + [ + { + backgroundColor: (theme) => theme.darkOverlay, + marginHorizontal: ts(3), + }, + !hover && { + display: "none", + }, + ], + touchOnly, + ); return ( - {previousSlug && ( @@ -129,31 +131,22 @@ export const TouchControls = ({ href={previousSlug} replace size={ts(4)} - {...tooltip(t("player.previous"), true)} - {...spacing} + {...common} /> )} setPlay(!isPlaying)} size={ts(8)} - {...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} - {...spacing} + {...common} /> {nextSlug && ( - + )} - + ); }; + const VolumeSlider = () => { const [volume, setVolume] = useAtom(volumeAtom); const [isMuted, setMuted] = useAtom(mutedAtom); diff --git a/front/packages/ui/src/player/index.tsx b/front/packages/ui/src/player/index.tsx index d7f200a9..60c9f29f 100644 --- a/front/packages/ui/src/player/index.tsx +++ b/front/packages/ui/src/player/index.tsx @@ -31,13 +31,13 @@ import { } from "@kyoo/models"; import { Head } from "@kyoo/primitives"; import { useState, useEffect, ComponentProps } from "react"; -import { Platform, StyleSheet, View, PointerEvent as NativePointerEvent } from "react-native"; +import { Platform, StyleSheet, View } from "react-native"; import { useTranslation } from "react-i18next"; import { useRouter } from "solito/router"; -import { useAtom } from "jotai"; +import { useSetAtom } from "jotai"; import { useYoshiki } from "yoshiki/native"; import { Back, Hover, LoadingIndicator } from "./components/hover"; -import { fullscreenAtom, playAtom, Video } from "./state"; +import { fullscreenAtom, Video } from "./state"; import { episodeDisplayNumber } from "../details/episode"; import { useVideoKeyboard } from "./keyboard"; import { MediaSessionManager } from "./media-session"; @@ -86,14 +86,6 @@ const mapData = ( }; }; -// 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; -// Number of time the video has been pressed. Used to handle double click. Since there is only one player, -// this can be global and not in the state. -let touchCount = 0; -let touchTimeout: NodeJS.Timeout; - export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({ slug, type }) => { const { css } = useYoshiki(); const { t } = useTranslation(); @@ -113,65 +105,15 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({ useVideoKeyboard(info?.subtitles, info?.fonts, previous, next); - const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); - const [isPlaying, setPlay] = useAtom(playAtom); - const [showHover, setHover] = useState(false); - const [mouseMoved, setMouseMoved] = useState(false); - const [menuOpenned, setMenuOpen] = useState(false); - - const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned; - const show = () => { - setMouseMoved(true); - if (mouseCallback) clearTimeout(mouseCallback); - mouseCallback = setTimeout(() => { - setMouseMoved(false); - }, 2500); - }; + const setFullscreen = useSetAtom(fullscreenAtom); useEffect(() => { if (Platform.OS !== "web") return; - const handler = (e: PointerEvent) => { - if (e.pointerType !== "mouse") return; - show(); - }; - - document.addEventListener("pointermove", handler); - return () => document.removeEventListener("pointermove", handler); - }); - - useEffect(() => { - if (Platform.OS !== "web" || !/Mobi/i.test(window.navigator.userAgent)) return; - setFullscreen(true); + if (!/Mobi/i.test(window.navigator.userAgent)) setFullscreen(true); return () => { setFullscreen(false); }; }, [setFullscreen]); - const onPointerDown = (e: NativePointerEvent) => { - if (Platform.OS === "web") e.preventDefault(); - if (Platform.OS !== "web" || e.nativeEvent.pointerType !== "mouse") { - displayControls ? setMouseMoved(false) : show(); - return; - } - touchCount++; - if (touchCount == 2) { - touchCount = 0; - setFullscreen(!isFullscreen); - clearTimeout(touchTimeout); - } else - touchTimeout = setTimeout(() => { - touchCount = 0; - }, 400); - setPlay(!isPlaying); - }; - - // When the controls hide, remove focus so space can be used to play/pause instead of triggering the button - // It also serves to hide the tooltip. - useEffect(() => { - if (Platform.OS !== "web") return; - if (!displayControls && document.activeElement instanceof HTMLElement) - document.activeElement.blur(); - }, [displayControls]); - if (error || infoError || playbackError) return ( <> @@ -206,15 +148,10 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({ /> {data && } { - if (e.nativeEvent.pointerType === "mouse") setMouseMoved(false); - }} {...css({ flexGrow: 1, flexShrink: 1, bg: "black", - // @ts-ignore Web only - cursor: displayControls ? "unset" : "none", })} > ); diff --git a/front/packages/ui/src/player/state.tsx b/front/packages/ui/src/player/state.tsx index 25d4a629..eea8fa2d 100644 --- a/front/packages/ui/src/player/state.tsx +++ b/front/packages/ui/src/player/state.tsx @@ -39,9 +39,13 @@ export const durationAtom = atom(undefined); export const progressAtom = atom( (get) => get(privateProgressAtom), - (_, set, value: number) => { - set(privateProgressAtom, value); - set(publicProgressAtom, value); + (get, set, update: number | ((value: number) => number)) => { + const run = (value: number) => { + set(privateProgressAtom, value); + set(publicProgressAtom, value); + }; + if (typeof update === "function") run(update(get(privateProgressAtom))); + else run(update); }, ); const privateProgressAtom = atom(0); @@ -52,23 +56,27 @@ export const mutedAtom = atom(false); export const fullscreenAtom = atom( (get) => get(privateFullscreen), - async (_, set, value: boolean) => { - try { - if (value) { - await document.body.requestFullscreen({ - navigationUI: "hide", - }); - set(privateFullscreen, true); - // @ts-expect-error Firefox does not support this so ts complains - await screen.orientation.lock("landscape"); - } else { - await document.exitFullscreen(); - set(privateFullscreen, false); - screen.orientation.unlock(); + (get, set, update: boolean | ((value: boolean) => boolean)) => { + const run = async (value: boolean) => { + try { + if (value) { + await document.body.requestFullscreen({ + navigationUI: "hide", + }); + set(privateFullscreen, true); + // @ts-expect-error Firefox does not support this so ts complains + await screen.orientation.lock("landscape"); + } else { + if (document.fullscreenElement) await document.exitFullscreen(); + set(privateFullscreen, false); + screen.orientation.unlock(); + } + } catch (e) { + console.error(e); } - } catch (e) { - console.error(e); - } + }; + if (typeof update === "function") run(update(get(privateFullscreen))); + else run(update); }, ); const privateFullscreen = atom(false);