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);