mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-09-29 15:30:53 -04:00
475 lines
12 KiB
TypeScript
475 lines
12 KiB
TypeScript
/*
|
|
* Kyoo - A portable and vast media library solution.
|
|
* Copyright (c) Kyoo.
|
|
*
|
|
* See AUTHORS.md and LICENSE file in the project root for full license information.
|
|
*
|
|
* Kyoo is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* any later version.
|
|
*
|
|
* Kyoo is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import type { Audio, Chapter, KyooImage, Subtitle } from "@kyoo/models";
|
|
import {
|
|
CircularProgress,
|
|
ContrastArea,
|
|
H1,
|
|
H2,
|
|
IconButton,
|
|
Poster,
|
|
PressableFeedback,
|
|
Skeleton,
|
|
Slider,
|
|
Tooltip,
|
|
alpha,
|
|
imageBorderRadius,
|
|
tooltip,
|
|
ts,
|
|
useIsTouch,
|
|
} from "@kyoo/primitives";
|
|
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
|
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
import { atom } from "jotai";
|
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { type ImageStyle, Platform, Pressable, View, type ViewProps } from "react-native";
|
|
import { useRouter } from "solito/router";
|
|
import { percent, rem, useYoshiki } from "yoshiki/native";
|
|
import {
|
|
bufferedAtom,
|
|
durationAtom,
|
|
fullscreenAtom,
|
|
loadAtom,
|
|
playAtom,
|
|
progressAtom,
|
|
} from "../state";
|
|
import { LeftButtons, TouchControls } from "./left-buttons";
|
|
import { RightButtons } from "./right-buttons";
|
|
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
|
|
|
|
const hoverReasonAtom = atom({
|
|
mouseMoved: false,
|
|
mouseHover: false,
|
|
menuOpened: false,
|
|
});
|
|
export const hoverAtom = atom((get) =>
|
|
[!get(playAtom), ...Object.values(get(hoverReasonAtom))].includes(true),
|
|
);
|
|
export const seekingAtom = atom(false);
|
|
export const seekProgressAtom = atom<number | null>(null);
|
|
|
|
export const Hover = ({
|
|
isLoading,
|
|
url,
|
|
name,
|
|
showName,
|
|
poster,
|
|
chapters,
|
|
subtitles,
|
|
audios,
|
|
fonts,
|
|
previousSlug,
|
|
nextSlug,
|
|
}: {
|
|
isLoading: boolean;
|
|
url: string;
|
|
name?: string | null;
|
|
showName?: string;
|
|
poster?: KyooImage | null;
|
|
chapters?: Chapter[];
|
|
subtitles?: Subtitle[];
|
|
audios?: Audio[];
|
|
fonts?: string[];
|
|
previousSlug?: string | null;
|
|
nextSlug?: string | null;
|
|
}) => {
|
|
const show = useAtomValue(hoverAtom);
|
|
const setHover = useSetAtom(hoverReasonAtom);
|
|
const isSeeking = useAtomValue(seekingAtom);
|
|
const isTouch = useIsTouch();
|
|
|
|
const showBottomSeeker = isSeeking && isTouch;
|
|
|
|
return (
|
|
<ContrastArea mode="dark">
|
|
{({ css }) => (
|
|
<>
|
|
<TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
|
|
<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 }));
|
|
}}
|
|
{...css({
|
|
// TODO: animate show
|
|
display: !show ? "none" : "flex",
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 0,
|
|
right: 0,
|
|
// box-none does not work on the web while none does not work on android
|
|
pointerEvents: Platform.OS === "web" ? "none" : "box-none",
|
|
})}
|
|
>
|
|
<Back
|
|
isLoading={isLoading}
|
|
name={showName}
|
|
{...css({
|
|
pointerEvents: "auto",
|
|
})}
|
|
/>
|
|
<View
|
|
{...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",
|
|
pointerEvents: "auto",
|
|
padding: percent(1),
|
|
})}
|
|
>
|
|
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} />
|
|
<View
|
|
{...css({
|
|
marginLeft: { xs: ts(0.5), sm: ts(3) },
|
|
flexDirection: "column",
|
|
flexGrow: 1,
|
|
flexShrink: 1,
|
|
maxWidth: percent(100),
|
|
})}
|
|
>
|
|
{!showBottomSeeker && (
|
|
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
|
|
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
|
|
</H2>
|
|
)}
|
|
<ProgressBar chapters={chapters} url={url} />
|
|
{showBottomSeeker ? (
|
|
<BottomScrubber url={url} 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>
|
|
</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 isTouch = useIsTouch();
|
|
|
|
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: { pointerType: string; x: number }) => {
|
|
if (Platform.OS === "web" && e.pointerType === "mouse") {
|
|
setPlay((x) => !x);
|
|
return;
|
|
}
|
|
if (hover) setHover((x) => ({ ...x, mouseMoved: false }));
|
|
else show();
|
|
};
|
|
const onDoublePress = (e: { pointerType: string; x: number }) => {
|
|
if (Platform.OS === "web" && e.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;
|
|
}
|
|
|
|
show();
|
|
if (!duration || !playerWidth.current) return;
|
|
|
|
if (e.x < playerWidth.current * 0.33) {
|
|
setProgress((x) => Math.max(x - 10, 0));
|
|
}
|
|
if (e.x > playerWidth.current * 0.66) {
|
|
setProgress((x) => Math.min(x + 10, duration));
|
|
}
|
|
};
|
|
|
|
const onAnyPress = (e: { pointerType: string; x: number }) => {
|
|
touch.current.count++;
|
|
if (touch.current.count >= 2) {
|
|
onDoublePress(e);
|
|
clearTimeout(touch.current.timeout);
|
|
} else {
|
|
onPress(e);
|
|
}
|
|
|
|
touch.current.timeout = setTimeout(() => {
|
|
touch.current.count = 0;
|
|
touch.current.timeout = undefined;
|
|
}, 400);
|
|
};
|
|
|
|
return (
|
|
<Pressable
|
|
tabIndex={-1}
|
|
onPointerLeave={(e) => {
|
|
if (e.nativeEvent.pointerType === "mouse") setHover((x) => ({ ...x, mouseMoved: false }));
|
|
}}
|
|
onPress={(e) => {
|
|
e.preventDefault();
|
|
onAnyPress({
|
|
pointerType: isTouch ? "touch" : "mouse",
|
|
x: e.nativeEvent.locationX ?? e.nativeEvent.pageX,
|
|
});
|
|
}}
|
|
onLayout={(e) => {
|
|
playerWidth.current = e.nativeEvent.layout.width;
|
|
}}
|
|
{...css(
|
|
// @ts-expect-error Web only property (cursor: unset)
|
|
{
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
cursor: hover ? "unset" : "none",
|
|
},
|
|
props,
|
|
)}
|
|
>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
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 });
|
|
const [seekProgress, setSeekProgress] = useAtom(seekProgressAtom);
|
|
const setSeeking = useSetAtom(seekingAtom);
|
|
|
|
return (
|
|
<>
|
|
<Slider
|
|
progress={seekProgress ?? progress}
|
|
startSeek={() => {
|
|
setPlay(false);
|
|
setSeeking(true);
|
|
}}
|
|
endSeek={() => {
|
|
setSeeking(false);
|
|
setProgress(seekProgress!);
|
|
setSeekProgress(null);
|
|
setTimeout(() => setPlay(true), 10);
|
|
}}
|
|
onHover={(progress, layout) => {
|
|
setHoverProgress(progress);
|
|
setLayout(layout);
|
|
}}
|
|
setProgress={(progress) => setSeekProgress(progress)}
|
|
subtleProgress={buffered}
|
|
max={duration}
|
|
markers={chapters?.map((x) => x.startTime)}
|
|
dataSet={{ tooltipId: "progress-scrubber" }}
|
|
/>
|
|
<Tooltip
|
|
id={"progress-scrubber"}
|
|
isOpen={hoverProgress !== null}
|
|
place="top"
|
|
position={{ x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), y: layout.y }}
|
|
render={() =>
|
|
hoverProgress ? (
|
|
<ScrubberTooltip seconds={hoverProgress} chapters={chapters} url={url} />
|
|
) : null
|
|
}
|
|
opacity={1}
|
|
style={{ padding: 0, borderRadius: imageBorderRadius }}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const Back = ({
|
|
isLoading,
|
|
name,
|
|
...props
|
|
}: { isLoading: boolean; name?: string } & ViewProps) => {
|
|
const { css } = useYoshiki();
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
|
|
return (
|
|
<View
|
|
{...css(
|
|
{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bg: (theme) => theme.darkOverlay,
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
padding: percent(0.33),
|
|
color: "white",
|
|
},
|
|
props,
|
|
)}
|
|
>
|
|
<IconButton
|
|
icon={ArrowBack}
|
|
as={PressableFeedback}
|
|
onPress={router.back}
|
|
{...tooltip(t("player.back"))}
|
|
/>
|
|
<Skeleton>
|
|
{isLoading ? (
|
|
<Skeleton {...css({ width: rem(5) })} />
|
|
) : (
|
|
<H1
|
|
{...css({
|
|
alignSelf: "center",
|
|
fontSize: rem(1.5),
|
|
marginLeft: rem(1),
|
|
})}
|
|
>
|
|
{name}
|
|
</H1>
|
|
)}
|
|
</Skeleton>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const VideoPoster = ({
|
|
poster,
|
|
alt,
|
|
isLoading,
|
|
}: {
|
|
poster?: KyooImage | null;
|
|
alt?: string;
|
|
isLoading: boolean;
|
|
}) => {
|
|
const { css } = useYoshiki();
|
|
|
|
return (
|
|
<View
|
|
{...css({
|
|
width: "15%",
|
|
display: { xs: "none", sm: "flex" },
|
|
position: "relative",
|
|
})}
|
|
>
|
|
<Poster
|
|
src={poster}
|
|
quality="low"
|
|
alt={alt}
|
|
forcedLoading={isLoading}
|
|
layout={{ width: percent(100) }}
|
|
{...(css({ position: "absolute", bottom: 0 }) as { style: ImageStyle })}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export const LoadingIndicator = () => {
|
|
const isLoading = useAtomValue(loadAtom);
|
|
const { css } = useYoshiki();
|
|
|
|
if (!isLoading) return null;
|
|
|
|
return (
|
|
<View
|
|
{...css({
|
|
position: "absolute",
|
|
pointerEvents: "none",
|
|
top: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bg: (theme) => alpha(theme.colors.black, 0.3),
|
|
justifyContent: "center",
|
|
})}
|
|
>
|
|
<CircularProgress {...css({ alignSelf: "center" })} />
|
|
</View>
|
|
);
|
|
};
|