Add web scrubber on hover

This commit is contained in:
Zoe Roux 2024-01-25 14:24:43 +01:00
parent ec90862262
commit 7803f8f11a
5 changed files with 102 additions and 24 deletions

View File

@ -22,6 +22,7 @@ import { useRef, useState } from "react";
import { GestureResponderEvent, Platform, View } from "react-native"; import { GestureResponderEvent, Platform, View } from "react-native";
import { px, percent, Stylable, useYoshiki } from "yoshiki/native"; import { px, percent, Stylable, useYoshiki } from "yoshiki/native";
import { focusReset } from "./utils"; import { focusReset } from "./utils";
import { ViewProps } from "react-native-svg/lib/typescript/fabric/utils";
export const Slider = ({ export const Slider = ({
progress, progress,
@ -31,6 +32,7 @@ export const Slider = ({
setProgress, setProgress,
startSeek, startSeek,
endSeek, endSeek,
onHover,
size = 6, size = 6,
...props ...props
}: { }: {
@ -41,11 +43,15 @@ export const Slider = ({
setProgress: (progress: number) => void; setProgress: (progress: number) => void;
startSeek?: () => void; startSeek?: () => void;
endSeek?: () => void; endSeek?: () => void;
onHover?: (
position: number | null,
layout: { x: number; y: number; width: number; height: number },
) => void;
size?: number; size?: number;
} & Stylable) => { } & Partial<ViewProps>) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const ref = useRef<View>(null); const ref = useRef<View>(null);
const [layout, setLayout] = useState({ x: 0, width: 0 }); const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [isSeeking, setSeek] = useState(false); const [isSeeking, setSeek] = useState(false);
const [isHover, setHover] = useState(false); const [isHover, setHover] = useState(false);
const [isFocus, setFocus] = useState(false); const [isFocus, setFocus] = useState(false);
@ -68,7 +74,14 @@ export const Slider = ({
// @ts-ignore Web only // @ts-ignore Web only
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
// @ts-ignore Web only // @ts-ignore Web only
onMouseLeave={() => setHover(false)} onMouseLeave={() => {
setHover(false);
onHover?.(null, layout);
}}
// @ts-ignore Web only
onMouseMove={(e) =>
onHover?.(Math.max(0, Math.min((e.clientX - layout.x) / layout.width, 1) * max), layout)
}
tabIndex={0} tabIndex={0}
onFocus={() => setFocus(true)} onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)} onBlur={() => setFocus(false)}
@ -84,7 +97,9 @@ export const Slider = ({
onResponderStart={change} onResponderStart={change}
onResponderMove={change} onResponderMove={change}
onLayout={() => onLayout={() =>
ref.current?.measure((_, __, width, ___, pageX) => setLayout({ width: width, x: pageX })) ref.current?.measure((_, __, width, height, pageX, pageY) =>
setLayout({ width, height, x: pageX, y: pageY }),
)
} }
onKeyDown={(e: KeyboardEvent) => { onKeyDown={(e: KeyboardEvent) => {
switch (e.code) { switch (e.code) {

View File

@ -25,3 +25,6 @@ export const tooltip = (tooltip: string, up?: boolean) => ({
ToastAndroid.show(tooltip, ToastAndroid.SHORT); ToastAndroid.show(tooltip, ToastAndroid.SHORT);
}, },
}); });
import type { Tooltip as RTooltip } from "react-tooltip";
export declare const Tooltip: RTooltip;

View File

@ -42,3 +42,5 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => {
`}</style> `}</style>
); );
}; };
export { Tooltip } from "react-tooltip";

View File

@ -29,6 +29,7 @@ import {
PressableFeedback, PressableFeedback,
Skeleton, Skeleton,
Slider, Slider,
Tooltip,
tooltip, tooltip,
ts, ts,
} from "@kyoo/primitives"; } from "@kyoo/primitives";
@ -49,9 +50,9 @@ import {
playAtom, playAtom,
progressAtom, progressAtom,
} from "../state"; } from "../state";
import { ReactNode, useCallback, useEffect, useRef } from "react"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { atom } from "jotai"; import { atom } from "jotai";
import { BottomScrubber } from "./scrubber"; import { BottomScrubber, ScrubberTooltip } from "./scrubber";
const hoverReasonAtom = atom({ const hoverReasonAtom = atom({
mouseMoved: false, mouseMoved: false,
@ -151,8 +152,8 @@ export const Hover = ({
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}> <H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name} {isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
</H2> </H2>
<ProgressBar chapters={chapters} /> <ProgressBar url={url} chapters={chapters} />
<BottomScrubber url={url}/> <BottomScrubber url={url} />
<View <View
{...css({ {...css({
flexDirection: "row", flexDirection: "row",
@ -309,22 +310,42 @@ export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
); );
}; };
const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { const ProgressBar = ({ url, chapters }: { url: string; chapters?: Chapter[] }) => {
const [progress, setProgress] = useAtom(progressAtom); const [progress, setProgress] = useAtom(progressAtom);
const buffered = useAtomValue(bufferedAtom); const buffered = useAtomValue(bufferedAtom);
const duration = useAtomValue(durationAtom); const duration = useAtomValue(durationAtom);
const setPlay = useSetAtom(playAtom); const setPlay = useSetAtom(playAtom);
const [hoverProgress, setHoverProgress] = useState<number | null>(null);
const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
return ( return (
<Slider <>
progress={progress} <Slider
startSeek={() => setPlay(false)} progress={progress}
endSeek={() => setTimeout(() => setPlay(true), 10)} startSeek={() => setPlay(false)}
setProgress={setProgress} endSeek={() => setTimeout(() => setPlay(true), 10)}
subtleProgress={buffered} onHover={(progress, layout) => {
max={duration} setHoverProgress(progress);
markers={chapters?.map((x) => x.startTime)} setLayout(layout);
/> }}
setProgress={setProgress}
subtleProgress={buffered}
max={duration}
markers={chapters?.map((x) => x.startTime)}
dataSet={{ tooltipId: "progress-scrubber" }}
/>
<Tooltip
id={"progress-scrubber"}
isOpen={hoverProgress !== null}
imperativeModeOnly
position={{ x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), y: layout.y }}
render={() =>
hoverProgress ? (
<ScrubberTooltip seconds={hoverProgress} chapters={chapters} url={url} />
) : null
}
/>
</>
); );
}; };

View File

@ -18,10 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { useFetch, QueryIdentifier, imageFn } from "@kyoo/models"; import { useFetch, QueryIdentifier, imageFn, Chapter } from "@kyoo/models";
import { FastImage, P, imageBorderRadius, tooltip, ts } from "@kyoo/primitives"; import { FastImage, P, Tooltip, imageBorderRadius, tooltip, ts } from "@kyoo/primitives";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { percent, useYoshiki, px, vh } from "yoshiki/native"; import { percent, useYoshiki, px, vh, Theme } from "yoshiki/native";
import { ErrorView } from "../../fetch"; import { ErrorView } from "../../fetch";
import { ComponentProps, useEffect, useMemo } from "react"; import { ComponentProps, useEffect, useMemo } from "react";
import { CssObject } from "yoshiki/src/web/generator"; import { CssObject } from "yoshiki/src/web/generator";
@ -91,6 +91,43 @@ useScrubber.query = (url: string): QueryIdentifier<string> => ({
}, },
}); });
export const ScrubberTooltip = ({
url,
chapters,
seconds,
}: {
url: string;
chapters?: Chapter[];
seconds: number;
}) => {
const { info, error, stats } = useScrubber(url);
const { css } = useYoshiki();
if (error) return <ErrorView error={error} />;
const current = info.findLast((x) => x.to < seconds * 1000);
const chapter = chapters?.findLast((x) => x.endTime < seconds);
return (
<View {...css({ justifyContent: "center" })}>
{current && (
<FastImage
src={current.url}
alt={""}
width={current.width}
height={current.height}
x={current.x}
y={current.y}
columns={stats!.columns}
rows={stats!.rows}
/>
)}
<P>{toTimerString(seconds)}</P>
{chapter && <P>{chapter.name}</P>}
</View>
);
};
export const BottomScrubber = ({ url }: { url: string }) => { export const BottomScrubber = ({ url }: { url: string }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { info, error, stats } = useScrubber(url); const { info, error, stats } = useScrubber(url);
@ -124,8 +161,8 @@ export const BottomScrubber = ({ url }: { url: string }) => {
height={thumb.height} height={thumb.height}
x={thumb.x} x={thumb.x}
y={thumb.y} y={thumb.y}
columns={stats?.columns} columns={stats!.columns}
rows={stats?.rows} rows={stats!.rows}
/> />
))} ))}
</View> </View>
@ -153,7 +190,7 @@ export const BottomScrubber = ({ url }: { url: string }) => {
<P <P
{...css({ {...css({
textAlign: "center", textAlign: "center",
color: (theme) => theme.colors.white, color: (theme: Theme) => theme.colors.white,
bg: (theme) => theme.darkOverlay, bg: (theme) => theme.darkOverlay,
padding: ts(0.5), padding: ts(0.5),
borderRadius: imageBorderRadius, borderRadius: imageBorderRadius,