Completly rewrite hover handling and add seek double press

This commit is contained in:
Zoe Roux 2023-12-13 18:19:37 +01:00
parent fbc8e14125
commit 57b4463c01
4 changed files with 263 additions and 203 deletions

View File

@ -31,19 +31,43 @@ import {
Skeleton, Skeleton,
Slider, Slider,
tooltip, tooltip,
touchOnly,
ts, ts,
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { Chapter, KyooImage, Subtitle, Audio } from "@kyoo/models"; import { Chapter, KyooImage, Subtitle, Audio } from "@kyoo/models";
import { useAtomValue, useSetAtom, useAtom } from "jotai"; 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 { useTranslation } from "react-i18next";
import { percent, rem, useYoshiki } from "yoshiki/native"; import { percent, rem, useYoshiki } from "yoshiki/native";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import { LeftButtons, TouchControls } from "./left-buttons"; import { LeftButtons, TouchControls } from "./left-buttons";
import { RightButtons } from "./right-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 = ({ export const Hover = ({
isLoading, isLoading,
@ -57,11 +81,6 @@ export const Hover = ({
fonts, fonts,
previousSlug, previousSlug,
nextSlug, nextSlug,
onMenuOpen,
onMenuClose,
show,
onPointerDown,
...props
}: { }: {
isLoading: boolean; isLoading: boolean;
name?: string | null; name?: string | null;
@ -74,78 +93,207 @@ export const Hover = ({
fonts?: string[]; fonts?: string[];
previousSlug?: string | null; previousSlug?: string | null;
nextSlug?: string | null; nextSlug?: string | null;
onMenuOpen: () => void; }) => {
onMenuClose: () => void; const show = useAtomValue(hoverAtom);
show: boolean; const setHover = useSetAtom(hoverReasonAtom);
} & ViewProps) => {
// TODO: animate show
const opacity = !show && (Platform.OS === "web" ? { opacity: 0 } : { display: "none" as const });
return ( return (
<ContrastArea mode="dark"> <ContrastArea mode="dark">
{({ css }) => ( {({ css }) => (
<> <>
<Back isLoading={isLoading} name={showName} href={href} {...css(opacity, props)} />
<TouchControls previousSlug={previousSlug} nextSlug={nextSlug} /> <TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
<Pressable <View
tabIndex={-1} onPointerEnter={(e) => {
onPointerDown={onPointerDown} if (e.nativeEvent.pointerType === "mouse")
onPress={Platform.OS !== "web" ? () => onPointerDown?.({} as any) : undefined} setHover((x) => ({ ...x, mouseHover: true }));
{...css( }}
[ onPointerLeave={(e) => {
{ if (e.nativeEvent.pointerType === "mouse")
// Fixed is used because firefox android make the hover disapear under the navigation bar in absolute setHover((x) => ({ ...x, mouseHover: false }));
position: Platform.OS === "web" ? ("fixed" as any) : "absolute", }}
bottom: 0, pointerEvents="none"
left: 0, {...css({
right: 0, // TODO: animate show
bg: (theme) => theme.darkOverlay, display: !show ? "none" : "flex",
flexDirection: "row", position: "absolute",
padding: percent(1), top: 0,
}, left: 0,
opacity, bottom: 0,
], right: 0,
props, })}
)}
> >
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} /> <Back isLoading={isLoading} name={showName} href={href} pointerEvents="auto" />
<View <View
pointerEvents="auto"
{...css({ {...css({
marginLeft: { xs: ts(0.5), sm: ts(3) }, // Fixed is used because firefox android make the hover disapear under the navigation bar in absolute
flexDirection: "column", position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
flexGrow: 1, bottom: 0,
flexShrink: 1, left: 0,
maxWidth: percent(100), right: 0,
bg: (theme) => theme.darkOverlay,
flexDirection: "row",
padding: percent(1),
})} })}
> >
<H2 {...css({ paddingBottom: ts(1) })}> <VideoPoster poster={poster} alt={showName} isLoading={isLoading} />
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
</H2>
<ProgressBar chapters={chapters} />
<View <View
{...css({ {...css({
flexDirection: "row", marginLeft: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column",
flexGrow: 1, flexGrow: 1,
justifyContent: "space-between", flexShrink: 1,
flexWrap: "wrap", maxWidth: percent(100),
})} })}
> >
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} /> <H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
<RightButtons {isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
subtitles={subtitles} </H2>
audios={audios} <ProgressBar chapters={chapters} />
fonts={fonts} <View
onMenuOpen={onMenuOpen} {...css({
onMenuClose={onMenuClose} flexDirection: "row",
/> flexGrow: 1,
justifyContent: "space-between",
flexWrap: "wrap",
})}
>
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
<RightButtons
subtitles={subtitles}
audios={audios}
fonts={fonts}
onMenuOpen={() => setHover((x) => ({ ...x, menuOpened: true }))}
onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable.
setHover((x) => ({ ...x, menuOpened: false, mouseHover: false }));
}}
/>
</View>
</View> </View>
</View> </View>
</Pressable> </View>
</> </>
)} )}
</ContrastArea> </ContrastArea>
); );
}; };
export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
const hover = useAtomValue(hoverAtom);
const setHover = useSetAtom(hoverReasonAtom);
const mouseCallback = useRef<NodeJS.Timeout | null>(null);
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ count: 0 });
const playerWidth = useRef<number | null>(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 (
<Pressable
tabIndex={-1}
onPointerLeave={(e) => {
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}
</Pressable>
);
};
const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
const [progress, setProgress] = useAtom(progressAtom); const [progress, setProgress] = useAtom(progressAtom);
const buffered = useAtomValue(bufferedAtom); const buffered = useAtomValue(bufferedAtom);
@ -165,7 +313,7 @@ const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
); );
}; };
const Back = ({ export const Back = ({
isLoading, isLoading,
name, name,
href, href,

View File

@ -18,17 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { import { IconButton, Link, P, Slider, noTouch, tooltip, touchOnly, ts } from "@kyoo/primitives";
IconButton,
Link,
NoTouch,
P,
Slider,
noTouch,
tooltip,
touchOnly,
ts,
} from "@kyoo/primitives";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; 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 Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-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 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 VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
import { Stylable, px, useYoshiki } from "yoshiki/native"; import { Stylable, px, useYoshiki } from "yoshiki/native";
import { Component, ComponentProps } from "react"; import { HoverTouch, hoverAtom } from "./hover";
export const LeftButtons = ({ export const LeftButtons = ({
previousSlug, previousSlug,
@ -96,18 +86,30 @@ export const LeftButtons = ({
export const TouchControls = ({ export const TouchControls = ({
previousSlug, previousSlug,
nextSlug, nextSlug,
...props
}: { }: {
previousSlug?: string | null; previousSlug?: string | null;
nextSlug?: string | null; nextSlug?: string | null;
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation();
const [isPlaying, setPlay] = useAtom(playAtom); 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 ( return (
<View <HoverTouch
{...css( {...css(
{ {
flexDirection: "row", flexDirection: "row",
@ -119,7 +121,7 @@ export const TouchControls = ({
right: 0, right: 0,
bottom: 0, bottom: 0,
}, },
touchOnly, props,
)} )}
> >
{previousSlug && ( {previousSlug && (
@ -129,31 +131,22 @@ export const TouchControls = ({
href={previousSlug} href={previousSlug}
replace replace
size={ts(4)} size={ts(4)}
{...tooltip(t("player.previous"), true)} {...common}
{...spacing}
/> />
)} )}
<IconButton <IconButton
icon={isPlaying ? Pause : PlayArrow} icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)} onPress={() => setPlay(!isPlaying)}
size={ts(8)} size={ts(8)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} {...common}
{...spacing}
/> />
{nextSlug && ( {nextSlug && (
<IconButton <IconButton icon={SkipNext} as={Link} href={nextSlug} replace size={ts(4)} {...common} />
icon={SkipNext}
as={Link}
href={nextSlug}
replace
size={ts(4)}
{...tooltip(t("player.next"), true)}
{...spacing}
/>
)} )}
</View> </HoverTouch>
); );
}; };
const VolumeSlider = () => { const VolumeSlider = () => {
const [volume, setVolume] = useAtom(volumeAtom); const [volume, setVolume] = useAtom(volumeAtom);
const [isMuted, setMuted] = useAtom(mutedAtom); const [isMuted, setMuted] = useAtom(mutedAtom);

View File

@ -31,13 +31,13 @@ import {
} from "@kyoo/models"; } from "@kyoo/models";
import { Head } from "@kyoo/primitives"; import { Head } from "@kyoo/primitives";
import { useState, useEffect, ComponentProps } from "react"; 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 { useTranslation } from "react-i18next";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import { useAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { Back, Hover, LoadingIndicator } from "./components/hover"; import { Back, Hover, LoadingIndicator } from "./components/hover";
import { fullscreenAtom, playAtom, Video } from "./state"; import { fullscreenAtom, Video } from "./state";
import { episodeDisplayNumber } from "../details/episode"; import { episodeDisplayNumber } from "../details/episode";
import { useVideoKeyboard } from "./keyboard"; import { useVideoKeyboard } from "./keyboard";
import { MediaSessionManager } from "./media-session"; 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 }) => { export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({ slug, type }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
@ -113,65 +105,15 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({
useVideoKeyboard(info?.subtitles, info?.fonts, previous, next); useVideoKeyboard(info?.subtitles, info?.fonts, previous, next);
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); const setFullscreen = useSetAtom(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);
};
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (Platform.OS !== "web") return;
const handler = (e: PointerEvent) => { if (!/Mobi/i.test(window.navigator.userAgent)) setFullscreen(true);
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);
return () => { return () => {
setFullscreen(false); setFullscreen(false);
}; };
}, [setFullscreen]); }, [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) if (error || infoError || playbackError)
return ( return (
<> <>
@ -206,15 +148,10 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({
/> />
{data && <WatchStatusObserver type={type} slug={data.slug} />} {data && <WatchStatusObserver type={type} slug={data.slug} />}
<View <View
onPointerLeave={(e) => {
if (e.nativeEvent.pointerType === "mouse") setMouseMoved(false);
}}
{...css({ {...css({
flexGrow: 1, flexGrow: 1,
flexShrink: 1, flexShrink: 1,
bg: "black", bg: "black",
// @ts-ignore Web only
cursor: displayControls ? "unset" : "none",
})} })}
> >
<Video <Video
@ -222,7 +159,6 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({
subtitles={info?.subtitles} subtitles={info?.subtitles}
setError={setPlaybackError} setError={setPlaybackError}
fonts={info?.fonts} fonts={info?.fonts}
onPointerDown={(e) => onPointerDown(e)}
onEnd={() => { onEnd={() => {
if (!data) return; if (!data) return;
if (data.type === "movie") if (data.type === "movie")
@ -239,32 +175,7 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({
{...css(StyleSheet.absoluteFillObject)} {...css(StyleSheet.absoluteFillObject)}
/> />
<LoadingIndicator /> <LoadingIndicator />
<Hover <Hover {...mapData(data, info, previous, next)} />
{...mapData(data, info, previous, next)}
onPointerEnter={(e) => {
if (Platform.OS !== "web" || e.nativeEvent.pointerType === "mouse") setHover(true);
}}
onPointerLeave={(e) => {
if (e.nativeEvent.pointerType === "mouse") setHover(false);
}}
onPointerDown={(e) => {
if (!displayControls) {
onPointerDown(e);
if (Platform.OS === "web") e.preventDefault();
}
}}
onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable.
setHover(false);
setMenuOpen(false);
}}
show={displayControls}
{...css({
// @ts-ignore Web only
cursor: "unset",
})}
/>
</View> </View>
</> </>
); );

View File

@ -39,9 +39,13 @@ export const durationAtom = atom<number | undefined>(undefined);
export const progressAtom = atom( export const progressAtom = atom(
(get) => get(privateProgressAtom), (get) => get(privateProgressAtom),
(_, set, value: number) => { (get, set, update: number | ((value: number) => number)) => {
set(privateProgressAtom, value); const run = (value: number) => {
set(publicProgressAtom, value); set(privateProgressAtom, value);
set(publicProgressAtom, value);
};
if (typeof update === "function") run(update(get(privateProgressAtom)));
else run(update);
}, },
); );
const privateProgressAtom = atom(0); const privateProgressAtom = atom(0);
@ -52,23 +56,27 @@ export const mutedAtom = atom(false);
export const fullscreenAtom = atom( export const fullscreenAtom = atom(
(get) => get(privateFullscreen), (get) => get(privateFullscreen),
async (_, set, value: boolean) => { (get, set, update: boolean | ((value: boolean) => boolean)) => {
try { const run = async (value: boolean) => {
if (value) { try {
await document.body.requestFullscreen({ if (value) {
navigationUI: "hide", await document.body.requestFullscreen({
}); navigationUI: "hide",
set(privateFullscreen, true); });
// @ts-expect-error Firefox does not support this so ts complains set(privateFullscreen, true);
await screen.orientation.lock("landscape"); // @ts-expect-error Firefox does not support this so ts complains
} else { await screen.orientation.lock("landscape");
await document.exitFullscreen(); } else {
set(privateFullscreen, false); if (document.fullscreenElement) await document.exitFullscreen();
screen.orientation.unlock(); 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); const privateFullscreen = atom(false);