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 { px, percent, Stylable, useYoshiki } from "yoshiki/native";
import { focusReset } from "./utils";
import { ViewProps } from "react-native-svg/lib/typescript/fabric/utils";
export const Slider = ({
progress,
@ -31,6 +32,7 @@ export const Slider = ({
setProgress,
startSeek,
endSeek,
onHover,
size = 6,
...props
}: {
@ -41,11 +43,15 @@ export const Slider = ({
setProgress: (progress: number) => void;
startSeek?: () => void;
endSeek?: () => void;
onHover?: (
position: number | null,
layout: { x: number; y: number; width: number; height: number },
) => void;
size?: number;
} & Stylable) => {
} & Partial<ViewProps>) => {
const { css } = useYoshiki();
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 [isHover, setHover] = useState(false);
const [isFocus, setFocus] = useState(false);
@ -68,7 +74,14 @@ export const Slider = ({
// @ts-ignore Web only
onMouseEnter={() => setHover(true)}
// @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}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
@ -84,7 +97,9 @@ export const Slider = ({
onResponderStart={change}
onResponderMove={change}
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) => {
switch (e.code) {

View File

@ -25,3 +25,6 @@ export const tooltip = (tooltip: string, up?: boolean) => ({
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>
);
};
export { Tooltip } from "react-tooltip";

View File

@ -29,6 +29,7 @@ import {
PressableFeedback,
Skeleton,
Slider,
Tooltip,
tooltip,
ts,
} from "@kyoo/primitives";
@ -49,9 +50,9 @@ import {
playAtom,
progressAtom,
} from "../state";
import { ReactNode, useCallback, useEffect, useRef } from "react";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { atom } from "jotai";
import { BottomScrubber } from "./scrubber";
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
const hoverReasonAtom = atom({
mouseMoved: false,
@ -151,8 +152,8 @@ export const Hover = ({
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
</H2>
<ProgressBar chapters={chapters} />
<BottomScrubber url={url}/>
<ProgressBar url={url} chapters={chapters} />
<BottomScrubber url={url} />
<View
{...css({
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 buffered = useAtomValue(bufferedAtom);
const duration = useAtomValue(durationAtom);
const setPlay = useSetAtom(playAtom);
const [hoverProgress, setHoverProgress] = useState<number | null>(null);
const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
return (
<Slider
progress={progress}
startSeek={() => setPlay(false)}
endSeek={() => setTimeout(() => setPlay(true), 10)}
setProgress={setProgress}
subtleProgress={buffered}
max={duration}
markers={chapters?.map((x) => x.startTime)}
/>
<>
<Slider
progress={progress}
startSeek={() => setPlay(false)}
endSeek={() => setTimeout(() => setPlay(true), 10)}
onHover={(progress, layout) => {
setHoverProgress(progress);
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/>.
*/
import { useFetch, QueryIdentifier, imageFn } from "@kyoo/models";
import { FastImage, P, imageBorderRadius, tooltip, ts } from "@kyoo/primitives";
import { useFetch, QueryIdentifier, imageFn, Chapter } from "@kyoo/models";
import { FastImage, P, Tooltip, imageBorderRadius, tooltip, ts } from "@kyoo/primitives";
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 { ComponentProps, useEffect, useMemo } from "react";
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 }) => {
const { css } = useYoshiki();
const { info, error, stats } = useScrubber(url);
@ -124,8 +161,8 @@ export const BottomScrubber = ({ url }: { url: string }) => {
height={thumb.height}
x={thumb.x}
y={thumb.y}
columns={stats?.columns}
rows={stats?.rows}
columns={stats!.columns}
rows={stats!.rows}
/>
))}
</View>
@ -153,7 +190,7 @@ export const BottomScrubber = ({ url }: { url: string }) => {
<P
{...css({
textAlign: "center",
color: (theme) => theme.colors.white,
color: (theme: Theme) => theme.colors.white,
bg: (theme) => theme.darkOverlay,
padding: ts(0.5),
borderRadius: imageBorderRadius,