Implement player's enEnd & loading indicator

This commit is contained in:
Zoe Roux 2025-07-20 15:13:07 +02:00
parent 908b120a7c
commit ee32ecd527
No known key found for this signature in database
6 changed files with 112 additions and 194 deletions

View File

@ -7,16 +7,16 @@ export * from "./divider";
export * from "./icons"; export * from "./icons";
export * from "./image"; export * from "./image";
export * from "./image-background"; export * from "./image-background";
// export * from "./popup";
// export * from "./select";
export * from "./input"; export * from "./input";
export * from "./links"; export * from "./links";
// export * from "./progress";
// export * from "./slider";
// export * from "./snackbar"; // export * from "./snackbar";
// export * from "./alert"; // export * from "./alert";
export * from "./menu"; export * from "./menu";
export * from "./progress";
// export * from "./popup";
export * from "./select";
export * from "./skeleton"; export * from "./skeleton";
export * from "./slider";
export * from "./text"; export * from "./text";
export * from "./theme"; export * from "./theme";
export * from "./tooltip"; export * from "./tooltip";

View File

@ -1,26 +1,5 @@
/* import { ActivityIndicator } from "react-native";
* Kyoo - A portable and vast media library solution. import { type Stylable, useYoshiki } from "yoshiki/native";
* 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 { ActivityIndicator, Platform, View } from "react-native";
import { Circle, Svg } from "react-native-svg";
import { px, type Stylable, useYoshiki } from "yoshiki/native";
export const CircularProgress = ({ export const CircularProgress = ({
size = 48, size = 48,
@ -28,64 +7,9 @@ export const CircularProgress = ({
color, color,
...props ...props
}: { size?: number; tickness?: number; color?: string } & Stylable) => { }: { size?: number; tickness?: number; color?: string } & Stylable) => {
const { css, theme } = useYoshiki(); const { theme } = useYoshiki();
if (Platform.OS !== "web")
return ( return (
<ActivityIndicator size={size} color={color ?? theme.accent} {...props} /> <ActivityIndicator size={size} color={color ?? theme.accent} {...props} />
); );
return (
<View {...css({ width: size, height: size, overflow: "hidden" }, props)}>
<style jsx global>{`
@keyframes circularProgress-svg {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes circularProgress-circle {
0% {
stroke-dasharray: 1px, 200px;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -15px;
}
100% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -125px;
}
}
`}</style>
<Svg
viewBox={`${size / 2} ${size / 2} ${size} ${size}`}
{...css(
// @ts-ignore Web only
Platform.OS === "web" && {
animation: "circularProgress-svg 1.4s ease-in-out infinite",
},
)}
>
<Circle
cx={size}
cy={size}
r={(size - tickness) / 2}
strokeWidth={tickness}
fill="none"
stroke={color ?? theme.accent}
strokeDasharray={[px(80), px(200)]}
{...css(
Platform.OS === "web" && {
// @ts-ignore Web only
animation: "circularProgress-circle 1.4s ease-in-out infinite",
},
)}
/>
</Svg>
</View>
);
}; };

View File

@ -1,23 +1,3 @@
/*
* 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 ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg"; import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import { Button } from "./button"; import { Button } from "./button";
import { Icon } from "./icons"; import { Icon } from "./icons";

View File

@ -1,26 +1,10 @@
/*
* 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 { useRef, useState } from "react"; import { useRef, useState } from "react";
import { type GestureResponderEvent, Platform, View } from "react-native"; import {
import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils"; type GestureResponderEvent,
Platform,
View,
type ViewProps,
} from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native"; import { percent, px, useYoshiki } from "yoshiki/native";
import { focusReset } from "./utils"; import { focusReset } from "./utils";

View File

@ -1,25 +1,24 @@
/* import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
* 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 { 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, CircularProgress,
ContrastArea, ContrastArea,
H1, H1,
@ -30,43 +29,14 @@ import {
Skeleton, Skeleton,
Slider, Slider,
Tooltip, Tooltip,
alpha,
imageBorderRadius,
tooltip, tooltip,
ts, ts,
useIsTouch, useIsTouch,
} from "@kyoo/primitives"; } from "~/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 { LeftButtons, TouchControls } from "./left-buttons";
import { RightButtons } from "./right-buttons"; import { RightButtons } from "./right-buttons";
import { BottomScrubber, ScrubberTooltip } from "./scrubber"; 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 = ({ export const Hover = ({
isLoading, isLoading,
url, url,
@ -145,7 +115,11 @@ export const Hover = ({
padding: percent(1), padding: percent(1),
})} })}
> >
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} /> <VideoPoster
poster={poster}
alt={showName}
isLoading={isLoading}
/>
<View <View
{...css({ {...css({
marginLeft: { xs: ts(0.5), sm: ts(3) }, marginLeft: { xs: ts(0.5), sm: ts(3) },
@ -157,7 +131,11 @@ export const Hover = ({
> >
{!showBottomSeeker && ( {!showBottomSeeker && (
<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} url={url} /> <ProgressBar chapters={chapters} url={url} />
@ -172,15 +150,24 @@ export const Hover = ({
flexWrap: "wrap", flexWrap: "wrap",
})} })}
> >
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} /> <LeftButtons
previousSlug={previousSlug}
nextSlug={nextSlug}
/>
<RightButtons <RightButtons
subtitles={subtitles} subtitles={subtitles}
audios={audios} audios={audios}
fonts={fonts} fonts={fonts}
onMenuOpen={() => setHover((x) => ({ ...x, menuOpened: true }))} onMenuOpen={() =>
setHover((x) => ({ ...x, menuOpened: true }))
}
onMenuClose={() => { onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable. // Disable hover since the menu overlay makes the mouseout unreliable.
setHover((x) => ({ ...x, menuOpened: false, mouseHover: false })); setHover((x) => ({
...x,
menuOpened: false,
mouseHover: false,
}));
}} }}
/> />
</View> </View>
@ -198,7 +185,9 @@ export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
const hover = useAtomValue(hoverAtom); const hover = useAtomValue(hoverAtom);
const setHover = useSetAtom(hoverReasonAtom); const setHover = useSetAtom(hoverReasonAtom);
const mouseCallback = useRef<NodeJS.Timeout | null>(null); const mouseCallback = useRef<NodeJS.Timeout | null>(null);
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ count: 0 }); const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({
count: 0,
});
const playerWidth = useRef<number | null>(null); const playerWidth = useRef<number | null>(null);
const isTouch = useIsTouch(); const isTouch = useIsTouch();
@ -226,7 +215,8 @@ export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
// It also serves to hide the tooltip. // It also serves to hide the tooltip.
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") return; if (Platform.OS !== "web") return;
if (!hover && document.activeElement instanceof HTMLElement) document.activeElement.blur(); if (!hover && document.activeElement instanceof HTMLElement)
document.activeElement.blur();
}, [hover]); }, [hover]);
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -282,7 +272,8 @@ export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
<Pressable <Pressable
tabIndex={-1} tabIndex={-1}
onPointerLeave={(e) => { onPointerLeave={(e) => {
if (e.nativeEvent.pointerType === "mouse") setHover((x) => ({ ...x, mouseMoved: false })); if (e.nativeEvent.pointerType === "mouse")
setHover((x) => ({ ...x, mouseMoved: false }));
}} }}
onPress={(e) => { onPress={(e) => {
e.preventDefault(); e.preventDefault();
@ -315,7 +306,13 @@ export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
); );
}; };
const ProgressBar = ({ url, chapters }: { url: string; 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);
@ -353,10 +350,17 @@ const ProgressBar = ({ url, chapters }: { url: string; chapters?: Chapter[] }) =
id={"progress-scrubber"} id={"progress-scrubber"}
isOpen={hoverProgress !== null} isOpen={hoverProgress !== null}
place="top" place="top"
position={{ x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), y: layout.y }} position={{
x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1),
y: layout.y,
}}
render={() => render={() =>
hoverProgress ? ( hoverProgress ? (
<ScrubberTooltip seconds={hoverProgress} chapters={chapters} url={url} /> <ScrubberTooltip
seconds={hoverProgress}
chapters={chapters}
url={url}
/>
) : null ) : null
} }
opacity={1} opacity={1}
@ -449,9 +453,13 @@ const VideoPoster = ({
); );
}; };
export const LoadingIndicator = () => { export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => {
const isLoading = useAtomValue(loadAtom);
const { css } = useYoshiki(); const { css } = useYoshiki();
const [isLoading, setLoading] = useState(false);
useEvent(player, "onStatusChange", (status) => {
setLoading(status === "loading");
});
if (!isLoading) return null; if (!isLoading) return null;

View File

@ -1,7 +1,12 @@
import { Stack } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { useVideoPlayer, VideoView, VideoViewRef } from "react-native-video"; import {
useEvent,
useVideoPlayer,
VideoView,
VideoViewRef,
} from "react-native-video";
import { entryDisplayNumber } from "~/components/entries"; import { entryDisplayNumber } from "~/components/entries";
import { FullVideo, VideoInfo } from "~/models"; import { FullVideo, VideoInfo } from "~/models";
import { Head } from "~/primitives"; import { Head } from "~/primitives";
@ -9,6 +14,7 @@ import { useToken } from "~/providers/account-context";
import { useLocalSetting } from "~/providers/settings"; import { useLocalSetting } from "~/providers/settings";
import { type QueryIdentifier, useFetch } from "~/query"; import { type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils"; import { useQueryState } from "~/utils";
import { LoadingIndicator } from "./components/hover";
// import { Hover, LoadingIndicator } from "./components/hover"; // import { Hover, LoadingIndicator } from "./components/hover";
// import { useVideoKeyboard } from "./keyboard"; // import { useVideoKeyboard } from "./keyboard";
@ -33,7 +39,8 @@ const mapMetadata = (item: FullVideo | undefined) => {
}; };
export const Player = () => { export const Player = () => {
const [slug] = useQueryState("slug", undefined!); const [slug, setSlug] = useQueryState<string>("slug", undefined!);
const [start, setStart] = useQueryState<number | undefined>("t", undefined);
const { data, error } = useFetch(Player.query(slug)); const { data, error } = useFetch(Player.query(slug));
const { data: info, error: infoError } = useFetch(Player.infoQuery(slug)); const { data: info, error: infoError } = useFetch(Player.infoQuery(slug));
@ -60,10 +67,25 @@ export const Player = () => {
(p) => { (p) => {
p.playWhenInactive = true; p.playWhenInactive = true;
p.playInBackground = true; p.playInBackground = true;
const seek = start ?? data?.progress.time;
// TODO: fix console.error bellow
if (seek) p.seekTo(seek);
else console.error("Player got ready before progress info was loaded.");
p.play(); p.play();
}, },
); );
const router = useRouter();
useEvent(player, "onEnd", () => {
if (!data) return;
if (data.next) {
setStart(0);
setSlug(data.next.video);
} else {
router.navigate(data.show!.href);
}
});
// const [playbackError, setPlaybackError] = useState<string | undefined>( // const [playbackError, setPlaybackError] = useState<string | undefined>(
// undefined, // undefined,
// ); // );
@ -120,7 +142,7 @@ export const Player = () => {
controls controls
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
/> />
{/* <LoadingIndicator /> */} <LoadingIndicator player={player} />
{/* <Hover {...mapData(data, info, previous, next)} url={`${type}/${slug}`} /> */} {/* <Hover {...mapData(data, info, previous, next)} url={`${type}/${slug}`} /> */}
</View> </View>
); );