Add load and error state

This commit is contained in:
Zoe Roux 2022-12-20 21:37:25 +09:00
parent c79a991024
commit 2c16fdad19
10 changed files with 175 additions and 114 deletions

View File

@ -119,7 +119,7 @@ const WatchMovieP = z.preprocess(
/** /**
* The title of this episode. * The title of this episode.
*/ */
name: z.string(), name: z.string().nullable(),
/** /**
* The sumarry of this episode. * The sumarry of this episode.
*/ */
@ -179,15 +179,15 @@ const WatchEpisodeP = WatchMovieP.and(
/** /**
* The season in witch this episode is in. * The season in witch this episode is in.
*/ */
seasonNumber: z.number(), seasonNumber: z.number().nullable(),
/** /**
* The number of this episode is it's season. * The number of this episode is it's season.
*/ */
episodeNumber: z.number(), episodeNumber: z.number().nullable(),
/** /**
* The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. * The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
*/ */
absoluteNumber: z.number(), absoluteNumber: z.number().nullable(),
/** /**
* The episode that come before this one if you follow usual watch orders. If this is the first * The episode that come before this one if you follow usual watch orders. If this is the first
* episode or this is a movie, it will be null. * episode or this is a movie, it will be null.

View File

@ -18,19 +18,21 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Platform, View, ViewProps } from "react-native"; import { ActivityIndicator, Platform, View } from "react-native";
import { Circle, Svg } from "react-native-svg"; import { Circle, Svg } from "react-native-svg";
import { px, useYoshiki } from "yoshiki/native"; import { px, Stylable, useYoshiki } from "yoshiki/native";
// TODO: Use moti on native
export const CircularProgress = ({ export const CircularProgress = ({
size = 48, size = 48,
tickness = 5, tickness = 5,
color, color,
...props ...props
}: { size?: number; tickness?: number; color?: string } & ViewProps) => { }: { size?: number; tickness?: number; color?: string } & Stylable) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
if (Platform.OS !== "web")
return <ActivityIndicator size={size} color={color ?? theme.accent} {...props} />;
return ( return (
<View {...css({ width: size, height: size, overflow: "hidden" }, props)}> <View {...css({ width: size, height: size, overflow: "hidden" }, props)}>
<style jsx global>{` <style jsx global>{`
@ -60,6 +62,7 @@ export const CircularProgress = ({
<Svg <Svg
viewBox={`${size / 2} ${size / 2} ${size} ${size}`} viewBox={`${size / 2} ${size / 2} ${size} ${size}`}
{...css( {...css(
// @ts-ignore Web only
Platform.OS === "web" && { animation: "circularProgress-svg 1.4s ease-in-out infinite" }, Platform.OS === "web" && { animation: "circularProgress-svg 1.4s ease-in-out infinite" },
)} )}
> >
@ -73,6 +76,7 @@ export const CircularProgress = ({
strokeDasharray={[px(80), px(200)]} strokeDasharray={[px(80), px(200)]}
{...css( {...css(
Platform.OS === "web" && { Platform.OS === "web" && {
// @ts-ignore Web only
animation: "circularProgress-circle 1.4s ease-in-out infinite", animation: "circularProgress-circle 1.4s ease-in-out infinite",
}, },
)} )}

View File

@ -65,7 +65,8 @@ export const ErrorView = ({ error }: { error: KyooErrors }) => {
<View <View
{...css({ {...css({
backgroundColor: (theme) => theme.colors.red, backgroundColor: (theme) => theme.colors.red,
flex: 1, flexGrow: 1,
flexShrink: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
})} })}
@ -85,7 +86,8 @@ export const EmptyView = ({ message }: { message: string }) => {
return ( return (
<View <View
{...css({ {...css({
flex: 1, flexGrow: 1,
flexShrink: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
})} })}

29
front/packages/ui/src/i18n.d.ts vendored Normal file
View File

@ -0,0 +1,29 @@
/*
* 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 { resources, defaultNS } from "./i18n";
import en from "../../../translations/en.json";
declare module "i18next" {
interface CustomTypeOptions {
returnNull: false;
resources: { translations: typeof en };
}
}

View File

@ -32,7 +32,8 @@ import {
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { Chapter, Font, Track } from "@kyoo/models"; import { Chapter, Font, Track } from "@kyoo/models";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { View, ViewProps } from "react-native"; import { Pressable, View, ViewProps } from "react-native";
import { useRouter } from "solito/router";
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import { LeftButtons } from "./left-buttons"; import { LeftButtons } from "./left-buttons";
import { RightButtons } from "./right-buttons"; import { RightButtons } from "./right-buttons";
@ -42,6 +43,7 @@ import { useTranslation } from "react-i18next";
import { percent, rem, useYoshiki } from "yoshiki/native"; import { percent, rem, useYoshiki } from "yoshiki/native";
export const Hover = ({ export const Hover = ({
isLoading,
name, name,
showName, showName,
href, href,
@ -53,8 +55,11 @@ export const Hover = ({
nextSlug, nextSlug,
onMenuOpen, onMenuOpen,
onMenuClose, onMenuClose,
show,
...props
}: { }: {
name?: string; isLoading: boolean;
name?: string | null;
showName?: string; showName?: string;
href?: string; href?: string;
poster?: string | null; poster?: string | null;
@ -65,22 +70,27 @@ export const Hover = ({
nextSlug?: string | null; nextSlug?: string | null;
onMenuOpen: () => void; onMenuOpen: () => void;
onMenuClose: () => void; onMenuClose: () => void;
}) => { show: boolean;
} & ViewProps) => {
// TODO animate show
return ( return (
<ContrastArea mode="dark"> <ContrastArea mode="dark">
{({ css }) => ( {({ css }) => (
<> <View {...css([{ flexGrow: 1 }, !show && { opacity: 0 }])}>
<Back name={showName} href={href} /> <Back isLoading={isLoading} name={showName} href={href} {...props} />
<View <View
{...css({ {...css(
position: "absolute", {
bottom: 0, position: "absolute",
left: 0, bottom: 0,
right: 0, left: 0,
bg: "rgba(0, 0, 0, 0.6)", right: 0,
flexDirection: "row", bg: "rgba(0, 0, 0, 0.6)",
padding: percent(1), flexDirection: "row",
})} padding: percent(1),
},
props,
)}
> >
<VideoPoster poster={poster} /> <VideoPoster poster={poster} />
<View <View
@ -90,7 +100,9 @@ export const Hover = ({
flexGrow: 1, flexGrow: 1,
})} })}
> >
<H2 {...css({ paddingBottom: ts(1) })}>{name ?? <Skeleton variant="fill" />}</H2> <H2 {...css({ paddingBottom: ts(1) })}>
{isLoading ? <Skeleton {...css({ width: rem(15), height: rem(2) })} /> : name}
</H2>
<ProgressBar chapters={chapters} /> <ProgressBar chapters={chapters} />
<View <View
{...css({ flexDirection: "row", flexGrow: 1, justifyContent: "space-between" })} {...css({ flexDirection: "row", flexGrow: 1, justifyContent: "space-between" })}
@ -105,33 +117,48 @@ export const Hover = ({
</View> </View>
</View> </View>
</View> </View>
</> </View>
)} )}
</ContrastArea> </ContrastArea>
); );
}; };
export const Back = ({ name, href }: { name?: string; href?: string }) => { export const Back = ({
isLoading,
name,
href,
...props
}: { isLoading: boolean; name?: string; href?: string } & ViewProps) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
return ( return (
<View <View
{...css({ {...css(
position: "absolute", {
top: 0, position: "absolute",
left: 0, top: 0,
right: 0, left: 0,
bg: "rgba(0, 0, 0, 0.6)", right: 0,
display: "flex", bg: "rgba(0, 0, 0, 0.6)",
flexDirection: "row", display: "flex",
alignItems: "center", flexDirection: "row",
padding: percent(0.33), alignItems: "center",
color: "white", padding: percent(0.33),
})} color: "white",
},
props,
)}
> >
<IconButton icon={ArrowBack} as={Link} href={href ?? ""} {...tooltip(t("back"))} /> <IconButton
icon={ArrowBack}
{...(href ? { as: Link as any, href: href } : { as: Pressable, onPress: router.back })}
{...tooltip(t("player.back"))}
/>
<Skeleton> <Skeleton>
{name ? ( {isLoading ? (
<Skeleton {...css({ width: rem(5), marginBottom: 0 })} />
) : (
<H1 <H1
{...css({ {...css({
alignSelf: "center", alignSelf: "center",
@ -142,8 +169,6 @@ export const Back = ({ name, href }: { name?: string; href?: string }) => {
> >
{name} {name}
</H1> </H1>
) : (
<Skeleton {...css({ width: rem(5), marginBottom: 0 })} />
)} )}
</Skeleton> </Skeleton>
</View> </View>
@ -185,7 +210,6 @@ export const LoadingIndicator = () => {
left: 0, left: 0,
right: 0, right: 0,
bg: "rgba(0, 0, 0, 0.3)", bg: "rgba(0, 0, 0, 0.3)",
display: "flex",
justifyContent: "center", justifyContent: "center",
})} })}
> >

View File

@ -68,7 +68,7 @@ export const LeftButtons = ({
icon={SkipNext} icon={SkipNext}
as={Link} as={Link}
href={nextSlug} href={nextSlug}
{...tooltip(t("next"))} {...tooltip(t("player.next"))}
{...spacing} {...spacing}
/> />
)} )}

View File

@ -22,7 +22,7 @@ import { Chapter } from "@kyoo/models";
import { ts, Slider } from "@kyoo/primitives"; import { ts, Slider } from "@kyoo/primitives";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { NativeTouchEvent, Pressable, Touchable, View } from "react-native"; import { NativeTouchEvent, Pressable, View } from "react-native";
import { useYoshiki, px, percent } from "yoshiki/native"; import { useYoshiki, px, percent } from "yoshiki/native";
import { bufferedAtom, durationAtom, progressAtom } from "../state"; import { bufferedAtom, durationAtom, progressAtom } from "../state";
@ -34,9 +34,10 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
return ( return (
<Slider <Slider
progress={progress} progress={progress}
setProgress={setProgress}
subtleProgress={buffered} subtleProgress={buffered}
max={duration} max={duration}
markers={chapters?.map((x) => x.startTime)} markers={chapters?.map((x) => x.startTime * 1000)}
/> />
); );
const { css } = useYoshiki(); const { css } = useYoshiki();

View File

@ -75,7 +75,7 @@ export const RightButtons = ({
<IconButton <IconButton
icon={isFullscreen ? FullscreenExit : Fullscreen} icon={isFullscreen ? FullscreenExit : Fullscreen}
onClick={() => setFullscreen(!isFullscreen)} onClick={() => setFullscreen(!isFullscreen)}
{...tooltip(t("fullscreen"))} {...tooltip(t("player.fullscreen"))}
sx={{ color: "white" }} sx={{ color: "white" }}
/> />
{/* {subtitleAnchor && ( */} {/* {subtitleAnchor && ( */}

View File

@ -21,16 +21,17 @@
import { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models"; import { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models";
import { Head } from "@kyoo/primitives"; import { Head } from "@kyoo/primitives";
import { useState, useEffect, PointerEvent as ReactPointerEvent, ComponentProps } from "react"; import { useState, useEffect, PointerEvent as ReactPointerEvent, ComponentProps } from "react";
import { PointerEvent, StyleSheet, View } from "react-native"; import { Platform, Pressable, StyleSheet, View } from "react-native";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import { percent, useYoshiki } from "yoshiki/native"; import { percent, useYoshiki } from "yoshiki/native";
import { Hover, LoadingIndicator } from "./components/hover"; import { Back, Hover, LoadingIndicator } from "./components/hover";
import { fullscreenAtom, playAtom, Video } from "./state"; import { fullscreenAtom, playAtom, Video } from "./state";
import { episodeDisplayNumber } from "../details/episode"; import { episodeDisplayNumber } from "../details/episode";
import { useVideoKeyboard } from "./keyboard"; import { useVideoKeyboard } from "./keyboard";
import { MediaSessionManager } from "./media-session"; import { MediaSessionManager } from "./media-session";
import { ErrorView } from "../fetch"; import { ErrorView } from "../fetch";
import { useTranslation } from "react-i18next";
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout // Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move) // if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
@ -46,10 +47,11 @@ const mapData = (
previousSlug?: string, previousSlug?: string,
nextSlug?: string, nextSlug?: string,
): Partial<ComponentProps<typeof Hover>> => { ): Partial<ComponentProps<typeof Hover>> => {
if (!data) return {}; if (!data) return { isLoading: true };
return { return {
isLoading: false,
name: data.isMovie ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`, name: data.isMovie ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`,
showName: data.isMovie ? data.name : data.showTitle, showName: data.isMovie ? data.name! : data.showTitle,
href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#", href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#",
poster: data.poster, poster: data.poster,
subtitles: data.subtitles, subtitles: data.subtitles,
@ -60,10 +62,11 @@ const mapData = (
}; };
}; };
export const Player: QueryPage<{ slug: string }> = ({ slug }) => { export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation();
const [playbackError, setPlaybackError] = useState<string | undefined>(undefined);
const { data, error } = useFetch(query(slug)); const { data, error } = useFetch(query(slug));
const previous = const previous =
data && !data.isMovie && data.previousEpisode data && !data.isMovie && data.previousEpisode
@ -84,33 +87,40 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const [menuOpenned, setMenuOpen] = useState(false); const [menuOpenned, setMenuOpen] = useState(false);
const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned; const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned;
// const mouseHasMoved = () => { const show = () => {
// setMouseMoved(true); setMouseMoved(true);
// if (mouseCallback) clearTimeout(mouseCallback); if (mouseCallback) clearTimeout(mouseCallback);
// mouseCallback = setTimeout(() => { mouseCallback = setTimeout(() => {
// setMouseMoved(false); setMouseMoved(false);
// }, 2500); }, 2500);
// }; };
// useEffect(() => { useEffect(() => {
// const handler = (e: PointerEvent) => { if (Platform.OS !== "web") return;
// if (e.pointerType !== "mouse") return; const handler = (e: PointerEvent) => {
// mouseHasMoved(); if (e.pointerType !== "mouse") return;
// }; show();
};
// document.addEventListener("pointermove", handler); document.addEventListener("pointermove", handler);
// return () => document.removeEventListener("pointermove", handler); return () => document.removeEventListener("pointermove", handler);
// }); });
// useEffect(() => { // useEffect(() => {
// setPlay(true); // setPlay(true);
// }, [slug, setPlay]); // }, [slug, setPlay]);
// useEffect(() => { useEffect(() => {
// if (!/Mobi/i.test(window.navigator.userAgent)) return; if (Platform.OS !== "web" || !/Mobi/i.test(window.navigator.userAgent)) return;
// setFullscreen(true); setFullscreen(true);
// return () => setFullscreen(false); return () => setFullscreen(false);
// }, [setFullscreen]); }, [setFullscreen]);
if (error) return <ErrorView error={error} />; if (error || playbackError)
return (
<>
<Back isLoading={false} {...css({ position: "relative", bg: (theme) => theme.appbar })} />
<ErrorView error={error ?? { errors: [playbackError!] }} />
</>
);
return ( return (
<> <>
@ -131,7 +141,7 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
/> />
)} )}
<MediaSessionManager <MediaSessionManager
title={data?.name} title={data?.name ?? t("show.episodeNoMetadata")}
image={data?.thumbnail} image={data?.thumbnail}
next={next} next={next}
previous={previous} previous={previous}
@ -142,29 +152,20 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
{/* text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; */} {/* text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; */}
{/* } */} {/* } */}
{/* `}</style> */} {/* `}</style> */}
<View <Pressable
// onMouseLeave={() => setMouseMoved(false)} onHoverOut={() => setMouseMoved(false)}
onPress={Platform.OS === "web" ? () => setPlay(!isPlaying) : show}
{...css({ {...css({
flexGrow: 1, flexGrow: 1,
// @ts-ignore // @ts-ignore
// cursor: displayControls ? "unset" : "none",
bg: "black", bg: "black",
})} })}
> >
<Video <Video
links={data?.link} links={data?.link}
videoStyle={{ width: percent(100), height: percent(100) }} videoStyle={{ width: percent(100), height: percent(100) }}
setError={setPlaybackError}
{...css(StyleSheet.absoluteFillObject)} {...css(StyleSheet.absoluteFillObject)}
// onClick={onVideoClick}
// onPointerDown={(e: PointerEvent) => {
// if (e.type === "mouse") {
// onVideoClick();
// } else if (mouseMoved) {
// setMouseMoved(false);
// } else {
// // mouseHasMoved();
// }
// }}
// onEnded={() => { // onEnded={() => {
// if (!data) return; // if (!data) return;
// if (data.isMovie) router.push(`/movie/${data.slug}`); // if (data.isMovie) router.push(`/movie/${data.slug}`);
@ -174,34 +175,22 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
// ); // );
// }} // }}
/> />
{/* <LoadingIndicator /> */} <LoadingIndicator />
<Hover <Hover
{...mapData(data, previous, next)} {...mapData(data, previous, next)}
// onPointerOver={(e: ReactPointerEvent<HTMLElement>) => { // @ts-ignore Web only types
// if (e.pointerType === "mouse") setHover(true); onMouseEnter={() => setHover(true)}
// }} // @ts-ignore Web only types
// onPointerOut={() => setHover(false)} onMouseLeave={() => setHover(false)}
onMenuOpen={() => setMenuOpen(true)} onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => { onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable. // Disable hover since the menu overlay makes the mouseout unreliable.
setHover(false); setHover(false);
setMenuOpen(false); setMenuOpen(false);
}} }}
// sx={ show={displayControls}
// displayControls
// ? {
// visibility: "visible",
// opacity: 1,
// transition: "opacity .2s ease-in",
// }
// : {
// visibility: "hidden",
// opacity: 0,
// transition: "opacity .4s ease-out, visibility 0s .4s",
// }
// }
/> />
</View> </Pressable>
</> </>
); );
}; };

View File

@ -20,7 +20,7 @@
import { Font, Track, WatchItem } from "@kyoo/models"; import { Font, Track, WatchItem } from "@kyoo/models";
import { atom, useAtom, useSetAtom } from "jotai"; import { atom, useAtom, useSetAtom } from "jotai";
import { RefObject, useEffect, useRef, useState } from "react"; import { RefObject, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createParam } from "solito"; import { createParam } from "solito";
import { ResizeMode, Video as NativeVideo, VideoProps } from "expo-av"; import { ResizeMode, Video as NativeVideo, VideoProps } from "expo-av";
import SubtitleOctopus from "libass-wasm"; import SubtitleOctopus from "libass-wasm";
@ -34,8 +34,7 @@ enum PlayMode {
const playModeAtom = atom<PlayMode>(PlayMode.Direct); const playModeAtom = atom<PlayMode>(PlayMode.Direct);
export const playAtom = atom<boolean>(true); export const playAtom = atom(true);
export const loadAtom = atom(false); export const loadAtom = atom(false);
export const progressAtom = atom(0); export const progressAtom = atom(0);
export const bufferedAtom = atom(0); export const bufferedAtom = atom(0);
@ -69,10 +68,13 @@ export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker)
let hls: Hls | null = null; let hls: Hls | null = null;
export const Video = ({ links, ...props }: { links?: WatchItem["link"] } & VideoProps) => { export const Video = ({
links,
setError,
...props
}: { links?: WatchItem["link"]; setError: (error: string | undefined) => void } & VideoProps) => {
// const player = useRef<HTMLVideoElement>(null); // const player = useRef<HTMLVideoElement>(null);
// const setPlayer = useSetAtom(playerAtom); // const setPlayer = useSetAtom(playerAtom);
// const setLoad = useSetAtom(loadAtom);
// const setVolume = useSetAtom(_volumeAtom); // const setVolume = useSetAtom(_volumeAtom);
// const setMuted = useSetAtom(_mutedAtom); // const setMuted = useSetAtom(_mutedAtom);
// const setFullscreen = useSetAtom(fullscreenAtom); // const setFullscreen = useSetAtom(fullscreenAtom);
@ -80,6 +82,12 @@ export const Video = ({ links, ...props }: { links?: WatchItem["link"] } & Video
const ref = useRef<NativeVideo | null>(null); const ref = useRef<NativeVideo | null>(null);
const [isPlaying, setPlay] = useAtom(playAtom); const [isPlaying, setPlay] = useAtom(playAtom);
const setLoad = useSetAtom(loadAtom);
useLayoutEffect(() => {
setLoad(true);
}, [])
const [progress, setProgress] = useAtom(progressAtom); const [progress, setProgress] = useAtom(progressAtom);
const [buffered, setBuffered] = useAtom(bufferedAtom); const [buffered, setBuffered] = useAtom(bufferedAtom);
const [duration, setDuration] = useAtom(durationAtom); const [duration, setDuration] = useAtom(durationAtom);
@ -134,9 +142,13 @@ export const Video = ({ links, ...props }: { links?: WatchItem["link"] } & Video
source={links ? { uri: links.direct } : undefined} source={links ? { uri: links.direct } : undefined}
shouldPlay={isPlaying} shouldPlay={isPlaying}
onPlaybackStatusUpdate={(status) => { onPlaybackStatusUpdate={(status) => {
// TODO: Handle error state if (!status.isLoaded) {
if (!status.isLoaded) return; setLoad(true);
if (status.error) setError(status.error);
return;
}
setLoad(status.isPlaying !== status.shouldPlay);
setPlay(status.shouldPlay); setPlay(status.shouldPlay);
setProgress(status.positionMillis); setProgress(status.positionMillis);
setBuffered(status.playableDurationMillis ?? 0); setBuffered(status.playableDurationMillis ?? 0);