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,
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 (
<ContrastArea mode="dark">
{({ css }) => (
<>
<Back isLoading={isLoading} name={showName} href={href} {...css(opacity, props)} />
<TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
<Pressable
tabIndex={-1}
onPointerDown={onPointerDown}
onPress={Platform.OS !== "web" ? () => 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,
)}
<View
onPointerEnter={(e) => {
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,
})}
>
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} />
<Back isLoading={isLoading} name={showName} href={href} pointerEvents="auto" />
<View
pointerEvents="auto"
{...css({
marginLeft: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column",
flexGrow: 1,
flexShrink: 1,
maxWidth: percent(100),
// 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),
})}
>
<H2 {...css({ paddingBottom: ts(1) })}>
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
</H2>
<ProgressBar chapters={chapters} />
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} />
<View
{...css({
flexDirection: "row",
marginLeft: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column",
flexGrow: 1,
justifyContent: "space-between",
flexWrap: "wrap",
flexShrink: 1,
maxWidth: percent(100),
})}
>
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
<RightButtons
subtitles={subtitles}
audios={audios}
fonts={fonts}
onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose}
/>
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
</H2>
<ProgressBar chapters={chapters} />
<View
{...css({
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>
</Pressable>
</View>
</>
)}
</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 [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,

View File

@ -18,17 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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 (
<View
<HoverTouch
{...css(
{
flexDirection: "row",
@ -119,7 +121,7 @@ export const TouchControls = ({
right: 0,
bottom: 0,
},
touchOnly,
props,
)}
>
{previousSlug && (
@ -129,31 +131,22 @@ export const TouchControls = ({
href={previousSlug}
replace
size={ts(4)}
{...tooltip(t("player.previous"), true)}
{...spacing}
{...common}
/>
)}
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)}
size={ts(8)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
{...spacing}
{...common}
/>
{nextSlug && (
<IconButton
icon={SkipNext}
as={Link}
href={nextSlug}
replace
size={ts(4)}
{...tooltip(t("player.next"), true)}
{...spacing}
/>
<IconButton icon={SkipNext} as={Link} href={nextSlug} replace size={ts(4)} {...common} />
)}
</View>
</HoverTouch>
);
};
const VolumeSlider = () => {
const [volume, setVolume] = useAtom(volumeAtom);
const [isMuted, setMuted] = useAtom(mutedAtom);

View File

@ -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 && <WatchStatusObserver type={type} slug={data.slug} />}
<View
onPointerLeave={(e) => {
if (e.nativeEvent.pointerType === "mouse") setMouseMoved(false);
}}
{...css({
flexGrow: 1,
flexShrink: 1,
bg: "black",
// @ts-ignore Web only
cursor: displayControls ? "unset" : "none",
})}
>
<Video
@ -222,7 +159,6 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({
subtitles={info?.subtitles}
setError={setPlaybackError}
fonts={info?.fonts}
onPointerDown={(e) => onPointerDown(e)}
onEnd={() => {
if (!data) return;
if (data.type === "movie")
@ -239,32 +175,7 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({
{...css(StyleSheet.absoluteFillObject)}
/>
<LoadingIndicator />
<Hover
{...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",
})}
/>
<Hover {...mapData(data, info, previous, next)} />
</View>
</>
);

View File

@ -39,9 +39,13 @@ export const durationAtom = atom<number | undefined>(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);