Rewrite player's controls compenents

This commit is contained in:
Zoe Roux 2025-07-25 22:16:31 +02:00
parent a310ceaed5
commit fc9695a2dc
No known key found for this signature in database
17 changed files with 792 additions and 734 deletions

View File

@ -66,8 +66,12 @@ export const expo: ExpoConfig = {
[
"react-native-video",
{
enableNotificationControls: true,
enableAndroidPictureInPicture: true,
enableBackgroundAudio: true,
androidExtensions: {
useExoplayerDash: true,
useExoplayerHls: true,
},
},
],
],

View File

@ -1,482 +0,0 @@
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
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 { useEvent, type VideoPlayer } from "react-native-video";
import { percent, rem, useYoshiki } from "yoshiki/native";
import type { AudioTrack, Chapter, KImage, Subtitle } from "~/models";
import {
alpha,
CircularProgress,
ContrastArea,
H1,
H2,
IconButton,
Poster,
PressableFeedback,
Skeleton,
Slider,
Tooltip,
tooltip,
ts,
useIsTouch,
} from "~/primitives";
import { LeftButtons, TouchControls } from "./left-buttons";
import { RightButtons } from "./right-buttons";
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
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 = ({ player }: { player: VideoPlayer }) => {
const { css } = useYoshiki();
const [isLoading, setLoading] = useState(false);
useEvent(player, "onStatusChange", (status) => {
setLoading(status === "loading");
});
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>
);
};

View File

@ -1,221 +0,0 @@
/*
* 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 { IconButton, Link, P, Slider, noTouch, tooltip, touchOnly, ts } from "@kyoo/primitives";
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { useAtom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { type Stylable, px, useYoshiki } from "yoshiki/native";
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
import { HoverTouch, hoverAtom } from "./hover";
export const LeftButtons = ({
previousSlug,
nextSlug,
}: {
previousSlug?: string | null;
nextSlug?: string | null;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const [isPlaying, setPlay] = useAtom(playAtom);
const spacing = css({ marginHorizontal: ts(1) });
return (
<View {...css({ flexDirection: "row" })}>
<View {...css({ flexDirection: "row" }, noTouch)}>
{previousSlug && (
<IconButton
icon={SkipPrevious}
as={Link}
href={previousSlug}
replace
{...tooltip(t("player.previous"), true)}
{...spacing}
/>
)}
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
{...spacing}
/>
{nextSlug && (
<IconButton
icon={SkipNext}
as={Link}
href={nextSlug}
replace
{...tooltip(t("player.next"), true)}
{...spacing}
/>
)}
{Platform.OS === "web" && <VolumeSlider />}
</View>
<ProgressText {...css({ marginLeft: ts(1) })} />
</View>
);
};
export const TouchControls = ({
previousSlug,
nextSlug,
...props
}: {
previousSlug?: string | null;
nextSlug?: string | null;
}) => {
const { css } = useYoshiki();
const [isPlaying, setPlay] = useAtom(playAtom);
const hover = useAtomValue(hoverAtom);
const common = css(
[
{
backgroundColor: (theme) => theme.darkOverlay,
marginHorizontal: ts(3),
},
],
touchOnly,
);
return (
<HoverTouch
{...css(
{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
props,
)}
>
{hover && (
<>
<IconButton
icon={SkipPrevious}
as={Link}
href={previousSlug!}
replace
size={ts(4)}
{...css([!previousSlug && { opacity: 0, pointerEvents: "none" }], common)}
/>
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)}
size={ts(8)}
{...common}
/>
<IconButton
icon={SkipNext}
as={Link}
href={nextSlug!}
replace
size={ts(4)}
{...css([!nextSlug && { opacity: 0, pointerEvents: "none" }], common)}
/>
</>
)}
</HoverTouch>
);
};
const VolumeSlider = () => {
const [volume, setVolume] = useAtom(volumeAtom);
const [isMuted, setMuted] = useAtom(mutedAtom);
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<View
{...css({
display: { xs: "none", sm: "flex" },
alignItems: "center",
flexDirection: "row",
paddingRight: ts(1),
})}
>
<IconButton
icon={
isMuted || volume === 0
? VolumeOff
: volume < 25
? VolumeMute
: volume < 65
? VolumeDown
: VolumeUp
}
onPress={() => setMuted(!isMuted)}
{...tooltip(t("player.mute"), true)}
/>
<Slider
progress={volume}
setProgress={setVolume}
size={4}
{...css({ width: px(100) })}
{...tooltip(t("player.volume"), true)}
/>
</View>
);
};
const ProgressText = (props: Stylable) => {
const progress = useAtomValue(progressAtom);
const duration = useAtomValue(durationAtom);
const { css } = useYoshiki();
return (
<P {...css({ alignSelf: "center" }, props)}>
{toTimerString(progress, duration)} : {toTimerString(duration)}
</P>
);
};
export const toTimerString = (timer?: number, duration?: number) => {
if (!duration) duration = timer;
if (
timer === undefined ||
duration === undefined ||
Number.isNaN(duration) ||
Number.isNaN(timer)
)
return "??:??";
const h = Math.floor(timer / 3600);
const min = Math.floor((timer / 60) % 60);
const sec = Math.floor(timer % 60);
const fmt = (n: number) => n.toString().padStart(2, "0");
if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`;
return `${fmt(min)}:${fmt(sec)}`;
};

View File

@ -0,0 +1,88 @@
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { percent, rem, useYoshiki } from "yoshiki/native";
import {
H1,
IconButton,
PressableFeedback,
Skeleton,
tooltip,
} from "~/primitives";
export const Back = ({ name, ...props }: { 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"))}
/>
<H1
{...css({
alignSelf: "center",
fontSize: rem(1.5),
marginLeft: rem(1),
})}
>
{name}
</H1>
</View>
);
};
Back.Loader = (props: 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 {...css({ width: rem(5) })} />
</View>
);
};

View File

@ -0,0 +1,307 @@
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import { useRouter } from "expo-router";
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 { useEvent, type VideoPlayer } from "react-native-video";
import { percent, rem, useYoshiki } from "yoshiki/native";
import type { AudioTrack, Chapter, KImage, Subtitle } from "~/models";
import {
alpha,
CircularProgress,
H1,
H2,
IconButton,
Poster,
PressableFeedback,
Skeleton,
Slider,
Tooltip,
tooltip,
ts,
useIsTouch,
} from "~/primitives";
import { LeftButtons } from "./components/left-buttons";
import { RightButtons } from "./components/right-buttons";
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
export const Controls = ({
player,
title,
}: {
player: VideoPlayer;
title: string;
description: string | null;
poster: KImage | null;
}) => {
const { css } = useYoshiki();
// const show = useAtomValue(hoverAtom);
// const setHover = useSetAtom(hoverReasonAtom);
// const isSeeking = useAtomValue(seekingAtom);
// const isTouch = useIsTouch();
// const showBottomSeeker = isSeeking && isTouch;
// <TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
return (
<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
name={title}
{...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>
);
};
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 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>
);
};

View File

@ -0,0 +1,118 @@
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { px, useYoshiki } from "yoshiki/native";
import {
alpha,
CircularProgress,
IconButton,
Slider,
tooltip,
ts,
} from "~/primitives";
export const PlayButton = ({ player, ...props }: { player: VideoPlayer }) => {
const { t } = useTranslation();
const [playing, setPlay] = useState(player.isPlaying);
useEvent(player, "onPlaybackStateChange", (status) => {
setPlay(status.isPlaying);
});
return (
<IconButton
icon={playing ? Pause : PlayArrow}
onPress={() => {
if (playing) player.pause();
else player.play();
}}
{...tooltip(playing ? t("player.pause") : t("player.play"), true)}
{...props}
/>
);
};
export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const [volume, setVolume] = useState(player.volume);
useEvent(player, "onVolumeChange", setVolume);
// TODO: listen to `player.muted` changes (currently hook does not exists
// const [muted, setMuted] = useState(player.muted);
const muted = player.muted;
return (
<View
{...css(
{
display: { xs: "none", sm: "flex" },
alignItems: "center",
flexDirection: "row",
paddingRight: ts(1),
},
props,
)}
>
<IconButton
icon={
muted || volume === 0
? VolumeOff
: volume < 25
? VolumeMute
: volume < 65
? VolumeDown
: VolumeUp
}
onPress={() => {
player.muted = !muted;
}}
{...tooltip(t("player.mute"), true)}
/>
<Slider
progress={volume}
setProgress={(vol) => {
player.volume = vol;
}}
size={4}
{...css({ width: px(100) })}
{...tooltip(t("player.volume"), true)}
/>
</View>
);
};
export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => {
const { css } = useYoshiki();
const [isLoading, setLoading] = useState(false);
useEvent(player, "onStatusChange", (status) => {
setLoading(status === "loading");
});
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>
);
};

View File

@ -0,0 +1,111 @@
import { useState } from "react";
import type { TextProps } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import type { Chapter } from "~/models";
import { P, Slider } from "~/primitives";
export const ProgressBar = ({
player,
// url,
chapters,
}: {
player: VideoPlayer;
// url: string;
chapters?: Chapter[];
}) => {
const [duration, setDuration] = useState(player.duration || 100);
useEvent(player, "onLoad", (info) => {
if (info.duration) setDuration(info.duration);
});
const [progress, setProgress] = useState(player.currentTime || 0);
const [buffer, setBuffer] = useState(0);
useEvent(player, "onProgress", (progress) => {
setProgress(progress.currentTime);
setBuffer(progress.bufferDuration);
});
const [seek, setSeek] = useState<number | null>(null);
return (
<>
<Slider
progress={seek ?? progress}
subtleProgress={buffer}
max={duration}
startSeek={() => {
player.pause();
}}
setProgress={setSeek}
endSeek={() => {
setProgress(seek!);
setSeek(null);
setTimeout(player.play, 10);
}}
// onHover={(progress, layout) => {
// setHoverProgress(progress);
// setLayout(layout);
// }}
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 ProgressText = ({
player,
...props
}: { player: VideoPlayer } & TextProps) => {
const { css } = useYoshiki();
const [progress, setProgress] = useState(player.currentTime || 0);
useEvent(player, "onProgress", (progress) => {
setProgress(progress.currentTime);
});
const [duration, setDuration] = useState(player.duration || 100);
useEvent(player, "onLoad", (info) => {
if (info.duration) setDuration(info.duration);
});
return (
<P {...css({ alignSelf: "center" }, props)}>
{toTimerString(progress, duration)} : {toTimerString(duration)}
</P>
);
};
const toTimerString = (timer?: number, duration?: number) => {
if (!duration) duration = timer;
if (timer === undefined || Number.isNaN(timer)) return "??:??";
const h = Math.floor(timer / 3600);
const min = Math.floor((timer / 60) % 60);
const sec = Math.floor(timer % 60);
const fmt = (n: number) => n.toString().padStart(2, "0");
return h !== 0 || (duration && duration >= 3600)
? `${fmt(h)}:${fmt(min)}:${fmt(sec)}`
: `${fmt(min)}:${fmt(sec)}`;
};

View File

@ -1,24 +1,14 @@
import { Stack, useRouter } from "expo-router";
import { useEffect, useRef } from "react";
import { StyleSheet, View } from "react-native";
import {
useEvent,
useVideoPlayer,
VideoView,
VideoViewRef,
} from "react-native-video";
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
import { entryDisplayNumber } from "~/components/entries";
import { FullVideo, VideoInfo } from "~/models";
import { Head } from "~/primitives";
import { ContrastArea, Head } from "~/primitives";
import { useToken } from "~/providers/account-context";
import { useLocalSetting } from "~/providers/settings";
import { type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils";
import { LoadingIndicator } from "./components/hover";
// import { Hover, LoadingIndicator } from "./components/hover";
// import { useVideoKeyboard } from "./keyboard";
// import { durationAtom, fullscreenAtom, Video } from "./state";
import { LoadingIndicator } from "./controls";
const mapMetadata = (item: FullVideo | undefined) => {
if (!item) return null;
@ -142,8 +132,10 @@ export const Player = () => {
controls
style={StyleSheet.absoluteFillObject}
/>
<LoadingIndicator player={player} />
{/* <Hover {...mapData(data, info, previous, next)} url={`${type}/${slug}`} /> */}
<ContrastArea mode="dark">
<LoadingIndicator player={player} />
<Controls player={player} {...metadata} />
</ContrastArea>
</View>
);
};

View File

@ -31,7 +31,7 @@ import {
progressAtom,
subtitleAtom,
volumeAtom,
} from "./state";
} from "./old/statee";
type Action =
| { type: "play" }

View File

@ -0,0 +1,141 @@
import {
IconButton,
Link,
noTouch,
tooltip,
touchOnly,
ts,
} from "@kyoo/primitives";
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
import { useAtom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { px, type Stylable, useYoshiki } from "yoshiki/native";
import { HoverTouch, hoverAtom } from "../controls";
import { playAtom } from "./state";
export const LeftButtons = ({
previousSlug,
nextSlug,
}: {
previousSlug?: string | null;
nextSlug?: string | null;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const [isPlaying, setPlay] = useAtom(playAtom);
const spacing = css({ marginHorizontal: ts(1) });
return (
<View {...css({ flexDirection: "row" })}>
<View {...css({ flexDirection: "row" }, noTouch)}>
{previousSlug && (
<IconButton
icon={SkipPrevious}
as={Link}
href={previousSlug}
replace
{...tooltip(t("player.previous"), true)}
{...spacing}
/>
)}
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
{...spacing}
/>
{nextSlug && (
<IconButton
icon={SkipNext}
as={Link}
href={nextSlug}
replace
{...tooltip(t("player.next"), true)}
{...spacing}
/>
)}
{Platform.OS === "web" && <VolumeSlider />}
</View>
<ProgressText {...css({ marginLeft: ts(1) })} />
</View>
);
};
export const TouchControls = ({
previousSlug,
nextSlug,
...props
}: {
previousSlug?: string | null;
nextSlug?: string | null;
}) => {
const { css } = useYoshiki();
const [isPlaying, setPlay] = useAtom(playAtom);
const hover = useAtomValue(hoverAtom);
const common = css(
[
{
backgroundColor: (theme) => theme.darkOverlay,
marginHorizontal: ts(3),
},
],
touchOnly,
);
return (
<HoverTouch
{...css(
{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
props,
)}
>
{hover && (
<>
<IconButton
icon={SkipPrevious}
as={Link}
href={previousSlug!}
replace
size={ts(4)}
{...css(
[!previousSlug && { opacity: 0, pointerEvents: "none" }],
common,
)}
/>
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)}
size={ts(8)}
{...common}
/>
<IconButton
icon={SkipNext}
as={Link}
href={nextSlug!}
replace
size={ts(4)}
{...css(
[!nextSlug && { opacity: 0, pointerEvents: "none" }],
common,
)}
/>
</>
)}
</HoverTouch>
);
};

View File

@ -21,8 +21,8 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useEffect } from "react";
import { useRouter } from "solito/router";
import { reducerAtom } from "./keyboard";
import { durationAtom, playAtom, progressAtom } from "./state";
import { reducerAtom } from "./old/keyboardd";
import { durationAtom, playAtom, progressAtom } from "./old/statee";
export const MediaSessionManager = ({
title,

View File

@ -30,8 +30,8 @@ import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { type Stylable, useYoshiki } from "yoshiki/native";
import { useSubtitleName } from "../../../../packages/ui/src/utils";
import { fullscreenAtom, subtitleAtom } from "../state";
import { AudiosMenu, QualitiesMenu } from "../video";
import { fullscreenAtom, subtitleAtom } from "./state";
import { AudiosMenu, QualitiesMenu } from "./video";
export const RightButtons = ({
audios,

View File

@ -25,9 +25,9 @@ import { useMemo } from "react";
import { Platform, View } from "react-native";
import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native";
import { ErrorView } from "../../errors";
import { durationAtom } from "../state";
import { seekProgressAtom } from "./hover";
import { toTimerString } from "./left-buttons";
import { durationAtom } from "./state";
import { seekProgressAtom } from "../controls";
import { toTimerString } from "../controls/left-buttonsttons";
type Thumb = {
from: number;

View File

@ -33,7 +33,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "./video";
import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "../videoideo";
export const playAtom = atom(true);
export const loadAtom = atom(false);

View File

@ -50,8 +50,8 @@ import NativeVideo, {
SelectedVideoTrackType,
} from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import { useDisplayName } from "../../../packages/ui/src/utils";
import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./state";
import { useDisplayName } from "../../../../packages/ui/src/utils";
import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./old/statee";
const MimeTypes: Map<string, string> = new Map([
["subrip", "application/x-subrip"],

View File

@ -36,9 +36,9 @@ import { useTranslation } from "react-i18next";
import type { VideoProps } from "react-native-video";
import toVttBlob from "srt-webvtt";
import { useForceRerender, useYoshiki } from "yoshiki";
import { useDisplayName } from "../../../packages/ui/src/utils";
import { MediaSessionManager } from "./media-session";
import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./state";
import { useDisplayName } from "../../../../packages/ui/src/utils";
import { MediaSessionManager } from "./old/media-sessionn";
import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./old/statee";
let hls: Hls | null = null;

View File

@ -23,7 +23,7 @@ import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { useCallback, useEffect } from "react";
import { playAtom, progressAtom } from "./state";
import { playAtom, progressAtom } from "./old/statee";
export const WatchStatusObserver = ({
type,