Rework of the hover

This commit is contained in:
Zoe Roux 2022-12-19 14:46:13 +09:00
parent 4f5023f745
commit 3c447f5708
17 changed files with 615 additions and 521 deletions

View File

@ -21,4 +21,10 @@
import { Player } from "@kyoo/ui"; import { Player } from "@kyoo/ui";
import { withRoute } from "../../utils"; import { withRoute } from "../../utils";
export default withRoute(Player); export default withRoute(Player, {
options: {
headerShown: false,
},
statusBar: { hidden: true },
fullscreen: true,
});

View File

@ -15,11 +15,14 @@
"@tanstack/react-query": "^4.19.1", "@tanstack/react-query": "^4.19.1",
"babel-plugin-transform-inline-environment-variables": "^0.4.4", "babel-plugin-transform-inline-environment-variables": "^0.4.4",
"expo": "^47.0.0", "expo": "^47.0.0",
"expo-av": "~13.0.2",
"expo-constants": "~14.0.2", "expo-constants": "~14.0.2",
"expo-linear-gradient": "~12.0.1", "expo-linear-gradient": "~12.0.1",
"expo-linking": "~3.2.3", "expo-linking": "~3.2.3",
"expo-localization": "~14.0.0", "expo-localization": "~14.0.0",
"expo-navigation-bar": "~2.0.1",
"expo-router": "^0.0.36", "expo-router": "^0.0.36",
"expo-screen-orientation": "~5.0.1",
"expo-status-bar": "~1.4.2", "expo-status-bar": "~1.4.2",
"i18next": "^22.0.6", "i18next": "^22.0.6",
"intl-pluralrules": "^1.3.1", "intl-pluralrules": "^1.3.1",

View File

@ -19,19 +19,37 @@
*/ */
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { ComponentType } from "react"; import { ComponentType, useEffect } from "react";
import { StatusBar, StatusBarProps } from "react-native"; import { StatusBar, StatusBarProps } from "react-native";
import * as ScreenOrientation from "expo-screen-orientation";
import * as NavigationBar from "expo-navigation-bar";
const FullscreenProvider = () => {
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
NavigationBar.setVisibilityAsync("hidden");
return () => {
ScreenOrientation.unlockAsync();
NavigationBar.setVisibilityAsync("visible");
};
}, []);
return null;
};
export const withRoute = <Props,>( export const withRoute = <Props,>(
Component: ComponentType<Props>, Component: ComponentType<Props>,
options?: Parameters<typeof Stack.Screen>[0] & { statusBar?: StatusBarProps }, options?: Parameters<typeof Stack.Screen>[0] & {
statusBar?: StatusBarProps;
fullscreen?: boolean;
},
) => { ) => {
const { statusBar, ...routeOptions } = options ?? {}; const { statusBar, fullscreen, ...routeOptions } = options ?? {};
const WithUseRoute = ({ route, ...props }: Props & { route: any }) => { const WithUseRoute = ({ route, ...props }: Props & { route: any }) => {
return ( return (
<> <>
{routeOptions && <Stack.Screen {...routeOptions} />} {routeOptions && <Stack.Screen {...routeOptions} />}
{statusBar && <StatusBar {...statusBar} />} {statusBar && <StatusBar {...statusBar} />}
{fullscreen && <FullscreenProvider />}
<Component {...route.params} {...props} /> <Component {...route.params} {...props} />
</> </>
); );

View File

@ -23,7 +23,7 @@ const CopyPlugin = require("copy-webpack-plugin");
const DefinePlugin = require("webpack").DefinePlugin; const DefinePlugin = require("webpack").DefinePlugin;
const withFont = require("next-fonts"); const withFont = require("next-fonts");
const suboctopus = path.dirname(require.resolve("@jellyfin/libass-wasm")); const suboctopus = path.dirname(require.resolve("libass-wasm"));
/** /**
* @type {import("next").NextConfig} * @type {import("next").NextConfig}
@ -115,6 +115,7 @@ const nextConfig = {
"@expo/html-elements", "@expo/html-elements",
"expo-font", "expo-font",
"expo-asset", "expo-asset",
"expo-av",
"expo-modules-core", "expo-modules-core",
"expo-linear-gradient", "expo-linear-gradient",
], ],

View File

@ -23,6 +23,7 @@
"@tanstack/react-query": "^4.19.1", "@tanstack/react-query": "^4.19.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"csstype": "^3.1.1", "csstype": "^3.1.1",
"expo-av": "^13.0.2",
"expo-linear-gradient": "^12.0.1", "expo-linear-gradient": "^12.0.1",
"hls.js": "^1.2.8", "hls.js": "^1.2.8",
"i18next": "^22.0.6", "i18next": "^22.0.6",

View File

@ -43,6 +43,7 @@ html, body, #__next {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden;
} }
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;

View File

@ -18,144 +18,178 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { ArrowBack } from "@mui/icons-material";
import { import {
Box,
BoxProps,
CircularProgress, CircularProgress,
ContrastArea,
H1,
H2,
IconButton, IconButton,
Link,
Poster,
Skeleton, Skeleton,
Tooltip, tooltip,
Typography, ts,
} from "@mui/material"; } from "@kyoo/primitives";
import useTranslation from "next-translate/useTranslation"; import { Chapter, Font, Track } from "@kyoo/models";
import NextLink from "next/link"; import { useAtomValue } from "jotai";
import { Poster } from "~/components/poster"; import { View, ViewProps } from "react-native";
import { WatchItem } from "~/models/resources/watch-item"; import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import { loadAtom } from "../state";
import { episodeDisplayNumber } from "~/components/episode";
import { LeftButtons } from "./left-buttons"; import { LeftButtons } from "./left-buttons";
import { RightButtons } from "./right-buttons"; import { RightButtons } from "./right-buttons";
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import { useAtomValue } from "jotai"; import { loadAtom } from "../state";
import { useTranslation } from "react-i18next";
import { percent, rem, useYoshiki } from "yoshiki/native";
export const Hover = ({ export const Hover = ({
data, name,
showName,
href,
poster,
chapters,
subtitles,
fonts,
previousSlug,
nextSlug,
onMenuOpen, onMenuOpen,
onMenuClose, onMenuClose,
...props }: {
}: { data?: WatchItem; onMenuOpen: () => void; onMenuClose: () => void } & BoxProps) => { name?: string;
const name = data showName?: string;
? data.isMovie href?: string;
? data.name poster?: string | null;
: `${episodeDisplayNumber(data, "")} ${data.name}` chapters?: Chapter[];
: undefined; subtitles?: Track[];
fonts?: Font[];
previousSlug?: string | null;
nextSlug?: string | null;
onMenuOpen: () => void;
onMenuClose: () => void;
}) => {
return ( return (
<Box {...props}> <ContrastArea mode="dark">
<Back {({ css }) => (
name={data?.name} <>
href={data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#"} <Back name={showName} href={href} />
/> <View
<Box {...css({
sx={{
position: "absolute", position: "absolute",
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
background: "rgba(0, 0, 0, 0.6)", bg: "rgba(0, 0, 0, 0.6)",
display: "flex", flexDirection: "row",
padding: "1%", padding: percent(1),
}} })}
> >
<VideoPoster poster={data?.poster} /> <VideoPoster poster={poster} />
<Box <View
sx={{ width: "100%", ml: { xs: 0.5, sm: 3 }, display: "flex", flexDirection: "column" }} {...css({
marginLeft: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column",
flexGrow: 1,
})}
> >
<Typography variant="h4" component="h2" color="white" sx={{ pb: 1 }}> <H2 {...css({ paddingBottom: ts(1) })}>{name ?? <Skeleton variant="fill" />}</H2>
{name ?? <Skeleton />} <ProgressBar chapters={chapters} />
</Typography> <View
{...css({ flexDirection: "row", flexGrow: 1, justifyContent: "space-between" })}
<ProgressBar chapters={data?.chapters} /> >
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
<Box sx={{ display: "flex", flexDirection: "row", justifyContent: "space-between" }}>
<LeftButtons
previousSlug={data && !data.isMovie ? data.previousEpisode?.slug : undefined}
nextSlug={data && !data.isMovie ? data.nextEpisode?.slug : undefined}
/>
<RightButtons <RightButtons
subtitles={data?.subtitles} subtitles={subtitles}
fonts={data?.fonts} fonts={fonts}
onMenuOpen={onMenuOpen} onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose} onMenuClose={onMenuClose}
/> />
</Box> </View>
</Box> </View>
</Box> </View>
</Box> </>
)}
</ContrastArea>
); );
}; };
export const Back = ({ name, href }: { name?: string; href: string }) => { export const Back = ({ name, href }: { name?: string; href?: string }) => {
const { t } = useTranslation("player"); const { css } = useYoshiki();
const { t } = useTranslation();
return ( return (
<Box <View
sx={{ {...css({
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
background: "rgba(0, 0, 0, 0.6)", bg: "rgba(0, 0, 0, 0.6)",
display: "flex", display: "flex",
p: "0.33%", flexDirection: "row",
alignItems: "center",
padding: percent(0.33),
color: "white", color: "white",
}} })}
> >
<Tooltip title={t("back")}> <IconButton icon={ArrowBack} as={Link} href={href ?? ""} {...tooltip(t("back"))} />
<NextLink href={href} passHref> <Skeleton>
<IconButton aria-label={t("back")} sx={{ color: "white" }}> {name ? (
<ArrowBack /> <H1
</IconButton> {...css({
</NextLink> alignSelf: "center",
</Tooltip> marginBottom: 0,
<Typography component="h1" variant="h5" sx={{ alignSelf: "center", ml: "1rem" }}> fontSize: rem(1.5),
{name ? name : <Skeleton />} marginLeft: rem(1),
</Typography> })}
</Box> >
{name}
</H1>
) : (
<Skeleton {...css({ width: rem(5), marginBottom: 0 })} />
)}
</Skeleton>
</View>
); );
}; };
const VideoPoster = ({ poster }: { poster?: string | null }) => { const VideoPoster = ({ poster }: { poster?: string | null }) => {
const { css } = useYoshiki();
return ( return (
<Box <View
sx={{ {...css({
width: "15%", width: "15%",
display: { xs: "none", sm: "block" }, display: { xs: "none", sm: "flex" },
position: "relative", position: "relative",
}} })}
> >
<Poster img={poster} width="100%" sx={{ position: "absolute", bottom: 0 }} /> <Poster
</Box> src={poster}
layout={{ width: percent(100) }}
{...css({ position: "absolute", bottom: 0 })}
/>
</View>
); );
}; };
export const LoadingIndicator = () => { export const LoadingIndicator = () => {
const isLoading = useAtomValue(loadAtom); const isLoading = useAtomValue(loadAtom);
const { css } = useYoshiki();
if (!isLoading) return null; if (!isLoading) return null;
return ( return (
<Box <View
sx={{ {...css({
position: "absolute", position: "absolute",
top: 0, top: 0,
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
background: "rgba(0, 0, 0, 0.3)", bg: "rgba(0, 0, 0, 0.3)",
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
}} })}
> >
<CircularProgress thickness={5} sx={{ color: "white", alignSelf: "center" }} /> <CircularProgress {...css({ alignSelf: "center" })} />
</Box> </View>
); );
}; };

View File

@ -18,109 +18,95 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Box, IconButton, Slider, Tooltip, Typography } from "@mui/material"; import { IconButton, Link, P, tooltip, ts } from "@kyoo/primitives";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import useTranslation from "next-translate/useTranslation"; import { useTranslation } from "react-i18next";
import { useRouter } from "next/router"; import { View } from "react-native";
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
import NextLink from "next/link"; import { useYoshiki } from "yoshiki/native";
import {
Pause,
PlayArrow,
SkipNext,
SkipPrevious,
VolumeDown,
VolumeMute,
VolumeOff,
VolumeUp,
} from "@mui/icons-material";
export const LeftButtons = ({ export const LeftButtons = ({
previousSlug, previousSlug,
nextSlug, nextSlug,
}: { }: {
previousSlug?: string; previousSlug?: string | null;
nextSlug?: string; nextSlug?: string | null;
}) => { }) => {
const { t } = useTranslation("player"); const { css } = useYoshiki();
const router = useRouter(); const { t } = useTranslation();
const [isPlaying, setPlay] = useAtom(playAtom); const [isPlaying, setPlay] = useAtom(playAtom);
const spacing = css({ marginHorizontal: ts(1) });
return ( return (
<Box <View {...css({ flexDirection: "row" })}>
sx={{
display: "flex",
"> *": {
mx: { xs: "2px !important", sm: "8px !important" },
p: { xs: "4px !important", sm: "8px !important" },
},
}}
>
{previousSlug && ( {previousSlug && (
<Tooltip title={t("previous")}>
<NextLink href={{ query: { ...router.query, slug: previousSlug } }} passHref>
<IconButton aria-label={t("previous")} sx={{ color: "white" }}>
<SkipPrevious />
</IconButton>
</NextLink>
</Tooltip>
)}
<Tooltip title={isPlaying ? t("pause") : t("play")}>
<IconButton <IconButton
icon={SkipPrevious}
as={Link}
href={previousSlug}
{...tooltip(t("player.previous"))}
{...spacing}
/>
)}
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onClick={() => setPlay(!isPlaying)} onClick={() => setPlay(!isPlaying)}
aria-label={isPlaying ? t("pause") : t("play")} {...tooltip(isPlaying ? t("player.pause") : t("player.play"))}
sx={{ color: "white" }} {...spacing}
> />
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
</Tooltip>
{nextSlug && ( {nextSlug && (
<Tooltip title={t("next")}> <IconButton
<NextLink href={{ query: { ...router.query, slug: nextSlug } }} passHref> icon={SkipNext}
<IconButton aria-label={t("next")} sx={{ color: "white" }}> as={Link}
<SkipNext /> href={nextSlug}
</IconButton> {...tooltip(t("next"))}
</NextLink> {...spacing}
</Tooltip> />
)} )}
<VolumeSlider /> <VolumeSlider />
<ProgressText /> <ProgressText />
</Box> </View>
); );
}; };
const VolumeSlider = () => { const VolumeSlider = () => {
const [volume, setVolume] = useAtom(volumeAtom); const [volume, setVolume] = useAtom(volumeAtom);
const [isMuted, setMuted] = useAtom(mutedAtom); const [isMuted, setMuted] = useAtom(mutedAtom);
const { t } = useTranslation("player"); const { css } = useYoshiki();
const { t } = useTranslation();
return null;
return ( return (
<Box <View
sx={{ {...css({
display: { xs: "none", sm: "flex" }, display: { xs: "none", sm: "flex" },
m: "0 !important", p: ts(1),
p: "8px",
"body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" }, "body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" },
}} })}
> >
<Tooltip title={t("mute")}>
<IconButton <IconButton
icon={
isMuted || volume == 0
? VolumeOff
: volume < 25
? VolumeMute
: volume < 65
? VolumeDown
: VolumeUp
}
onClick={() => setMuted(!isMuted)} onClick={() => setMuted(!isMuted)}
aria-label={t("mute")} {...tooltip(t("mute"))}
sx={{ color: "white" }} />
> <View
{isMuted || volume == 0 ? (
<VolumeOff />
) : volume < 25 ? (
<VolumeMute />
) : volume < 65 ? (
<VolumeDown />
) : (
<VolumeUp />
)}
</IconButton>
</Tooltip>
<Box
className="slider" className="slider"
sx={{ sx={{
width: 0, width: 0,
@ -136,19 +122,20 @@ const VolumeSlider = () => {
aria-label={t("volume")} aria-label={t("volume")}
sx={{ alignSelf: "center" }} sx={{ alignSelf: "center" }}
/> />
</Box> </View>
</Box> </View>
); );
}; };
const ProgressText = () => { const ProgressText = () => {
const progress = useAtomValue(progressAtom); const progress = useAtomValue(progressAtom);
const duration = useAtomValue(durationAtom); const duration = useAtomValue(durationAtom);
const { css } = useYoshiki();
return ( return (
<Typography color="white" sx={{ alignSelf: "center" }}> <P {...css({ alignSelf: "center", marginBottom: 0 })}>
{toTimerString(progress, duration)} : {toTimerString(duration)} {toTimerString(progress, duration)} : {toTimerString(duration)}
</Typography> </P>
); );
}; };

View File

@ -18,20 +18,24 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Box } from "@mui/material"; import { Chapter } from "@kyoo/models";
import { ts } 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 { Chapter } from "~/models/resources/watch-item"; import { NativeTouchEvent, Pressable, Touchable, View } from "react-native";
import { useYoshiki, px, percent } from "yoshiki/native";
import { bufferedAtom, durationAtom, progressAtom } from "../state"; import { bufferedAtom, durationAtom, progressAtom } from "../state";
export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => { export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
const ref = useRef<HTMLDivElement>(null); return null;
const { css } = useYoshiki();
const ref = useRef<View>(null);
const [isSeeking, setSeek] = useState(false); const [isSeeking, setSeek] = useState(false);
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);
const updateProgress = (event: MouseEvent | TouchEvent, skipSeek?: boolean) => { const updateProgress = (event: NativeTouchEvent, skipSeek?: boolean) => {
if (!(isSeeking || skipSeek) || !ref?.current) return; if (!(isSeeking || skipSeek) || !ref?.current) return;
const pageX: number = "pageX" in event ? event.pageX : event.changedTouches[0].pageX; const pageX: number = "pageX" in event ? event.pageX : event.changedTouches[0].pageX;
const value: number = (pageX - ref.current.offsetLeft) / ref.current.clientWidth; const value: number = (pageX - ref.current.offsetLeft) / ref.current.clientWidth;
@ -58,26 +62,25 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
}); });
return ( return (
<Box <Pressable
onMouseDown={(event) => { onPointerDown={(event) => {
// prevent drag and drop of the UI. // prevent drag and drop of the UI.
event.preventDefault(); event.preventDefault();
setSeek(true); setSeek(true);
}} }}
onTouchStart={() => setSeek(true)} onPress={(event) => updateProgress(event.nativeEvent, true)}
onClick={(event) => updateProgress(event.nativeEvent, true)} {...css({
sx={{ width: percent(100),
width: "100%", paddingVertical: ts(1),
py: 1,
cursor: "pointer", cursor: "pointer",
WebkitTapHighlightColor: "transparent", WebkitTapHighlightColor: "transparent",
"body.hoverEnabled &:hover": { "body.hoverEnabled &:hover": {
".thumb": { opacity: 1 }, ".thumb": { opacity: 1 },
".bar": { transform: "unset" }, ".bar": { transform: "unset" },
}, },
}} })}
> >
<Box <View
ref={ref} ref={ref}
className="bar" className="bar"
sx={{ sx={{
@ -88,7 +91,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
position: "relative", position: "relative",
}} }}
> >
<Box <View
sx={{ sx={{
width: `${(buffered / duration) * 100}%`, width: `${(buffered / duration) * 100}%`,
position: "absolute", position: "absolute",
@ -98,7 +101,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
background: "rgba(255, 255, 255, 0.5)", background: "rgba(255, 255, 255, 0.5)",
}} }}
/> />
<Box <View
sx={{ sx={{
width: `${(progress / duration) * 100}%`, width: `${(progress / duration) * 100}%`,
position: "absolute", position: "absolute",
@ -108,7 +111,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
background: (theme) => theme.palette.primary.main, background: (theme) => theme.palette.primary.main,
}} }}
/> />
<Box <View
className="thumb" className="thumb"
sx={{ sx={{
position: "absolute", position: "absolute",
@ -125,19 +128,19 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
/> />
{chapters?.map((x) => ( {chapters?.map((x) => (
<Box <View
key={x.startTime} key={x.startTime}
sx={{ {...css({
position: "absolute", position: "absolute",
width: "4px", width: px(4),
top: 0, top: 0,
bottom: 0, bottom: 0,
left: `${Math.min(100, (x.startTime / duration) * 100)}%`, left: `${Math.min(100, (x.startTime / duration) * 100)}%`,
background: (theme) => theme.palette.primary.dark, bg: (theme) => theme.accent,
}} })}
/> />
))} ))}
</Box> </View>
</Box> </Pressable>
); );
}; };

View File

@ -18,14 +18,15 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { ClosedCaption, Fullscreen, FullscreenExit } from "@mui/icons-material"; import { Font, Track } from "@kyoo/models";
import { Box, IconButton, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material"; import { IconButton, tooltip } from "@kyoo/primitives";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import useTranslation from "next-translate/useTranslation"; import { useRouter } from "solito/router";
import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import { Font, Track } from "~/models/resources/watch-item"; import { View } from "react-native";
import { Link } from "~/utils/link"; import { useTranslation } from "react-i18next";
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
import { fullscreenAtom, subtitleAtom } from "../state"; import { fullscreenAtom, subtitleAtom } from "../state";
export const RightButtons = ({ export const RightButtons = ({
@ -39,59 +40,56 @@ export const RightButtons = ({
onMenuOpen: () => void; onMenuOpen: () => void;
onMenuClose: () => void; onMenuClose: () => void;
}) => { }) => {
const { t } = useTranslation("player"); const { t } = useTranslation();
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null); const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
return ( return (
<Box <View
sx={{ // sx={{
display: "flex", // display: "flex",
"> *": { // "> *": {
m: { xs: "4px !important", sm: "8px !important" }, // m: { xs: "4px !important", sm: "8px !important" },
p: { xs: "4px !important", sm: "8px !important" }, // p: { xs: "4px !important", sm: "8px !important" },
}, // },
}} // }}
> >
{subtitles && ( {/* {subtitles && ( */}
<Tooltip title={t("subtitles")}> {/* <Tooltip title={t("subtitles")}> */}
<IconButton {/* <IconButton */}
id="sortby" {/* id="sortby" */}
aria-label={t("subtitles")} {/* aria-label={t("subtitles")} */}
aria-controls={subtitleAnchor ? "subtitle-menu" : undefined} {/* aria-controls={subtitleAnchor ? "subtitle-menu" : undefined} */}
aria-haspopup="true" {/* aria-haspopup="true" */}
aria-expanded={subtitleAnchor ? "true" : undefined} {/* aria-expanded={subtitleAnchor ? "true" : undefined} */}
onClick={(event) => { {/* onClick={(event) => { */}
setSubtitleAnchor(event.currentTarget); {/* setSubtitleAnchor(event.currentTarget); */}
onMenuOpen(); {/* onMenuOpen(); */}
}} {/* }} */}
sx={{ color: "white" }} {/* sx={{ color: "white" }} */}
> {/* > */}
<ClosedCaption /> {/* <ClosedCaption /> */}
</IconButton> {/* </IconButton> */}
</Tooltip> {/* </Tooltip> */}
)} {/* )} */}
<Tooltip title={t("fullscreen")}>
<IconButton <IconButton
icon={isFullscreen ? FullscreenExit : Fullscreen}
onClick={() => setFullscreen(!isFullscreen)} onClick={() => setFullscreen(!isFullscreen)}
aria-label={t("fullscreen")} {...tooltip(t("fullscreen"))}
sx={{ color: "white" }} sx={{ color: "white" }}
>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
{subtitleAnchor && (
<SubtitleMenu
subtitles={subtitles!}
fonts={fonts!}
anchor={subtitleAnchor}
onClose={() => {
setSubtitleAnchor(null);
onMenuClose();
}}
/> />
)} {/* {subtitleAnchor && ( */}
</Box> {/* <SubtitleMenu */}
{/* subtitles={subtitles!} */}
{/* fonts={fonts!} */}
{/* anchor={subtitleAnchor} */}
{/* onClose={() => { */}
{/* setSubtitleAnchor(null); */}
{/* onMenuClose(); */}
{/* }} */}
{/* /> */}
{/* )} */}
</View>
); );
}; };

View File

@ -0,0 +1,209 @@
/*
* 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 { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models";
import { Head } from "@kyoo/primitives";
import { useState, useEffect, PointerEvent as ReactPointerEvent, ComponentProps } from "react";
import { StyleSheet, View } from "react-native";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useRouter } from "solito/router";
import { Video } from "expo-av";
import { percent, useYoshiki } from "yoshiki/native";
import { Hover, LoadingIndicator } from "./components/hover";
import { fullscreenAtom, playAtom, useSubtitleController, useVideoController } from "./state";
import { episodeDisplayNumber } from "../details/episode";
import { useVideoKeyboard } from "./keyboard";
import { MediaSessionManager } from "./media-session";
import { ErrorView } from "../fetch";
// 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)
let mouseCallback: NodeJS.Timeout;
const query = (slug: string): QueryIdentifier<WatchItem> => ({
path: ["watch", slug],
parser: WatchItemP,
});
const mapData = (
data: WatchItem | undefined,
previousSlug: string,
nextSlug?: string,
): Partial<ComponentProps<typeof Hover>> => {
if (!data) return {};
return {
name: data.isMovie ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`,
showName: data.isMovie ? data.name : data.showTitle,
href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#",
poster: data.poster,
subtitles: data.subtitles,
chapters: data.chapters,
fonts: data.fonts,
previousSlug,
nextSlug,
};
};
export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { css } = useYoshiki();
const { data, error } = useFetch(query(slug));
const previous =
data && !data.isMovie && data.previousEpisode
? `/watch/${data.previousEpisode.slug}`
: undefined;
const next =
data && !data.isMovie && data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : undefined;
// const { playerRef, videoProps, onVideoClick } = useVideoController(data?.link);
// useSubtitleController(playerRef, data?.subtitles, data?.fonts);
// useVideoKeyboard(data?.subtitles, data?.fonts, previous, next);
const router = useRouter();
const setFullscreen = useSetAtom(fullscreenAtom);
const [isPlaying, setPlay] = useAtom(playAtom);
const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false);
const [menuOpenned, setMenuOpen] = useState(false);
const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned;
// const mouseHasMoved = () => {
// setMouseMoved(true);
// if (mouseCallback) clearTimeout(mouseCallback);
// mouseCallback = setTimeout(() => {
// setMouseMoved(false);
// }, 2500);
// };
// useEffect(() => {
// const handler = (e: PointerEvent) => {
// if (e.pointerType !== "mouse") return;
// mouseHasMoved();
// };
// document.addEventListener("pointermove", handler);
// return () => document.removeEventListener("pointermove", handler);
// });
// useEffect(() => {
// setPlay(true);
// }, [slug, setPlay]);
// useEffect(() => {
// if (!/Mobi/i.test(window.navigator.userAgent)) return;
// setFullscreen(true);
// return () => setFullscreen(false);
// }, [setFullscreen]);
if (error) return <ErrorView error={error} />;
return (
<>
{data && (
<Head
title={
data.isMovie
? data.name
: data.showTitle +
" " +
episodeDisplayNumber({
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
absoluteNumber: data.absoluteNumber,
})
}
description={data.overview}
/>
)}
<MediaSessionManager
title={data?.name}
image={data?.thumbnail}
next={next}
previous={previous}
/>
{/* <style jsx global>{` */}
{/* ::cue { */}
{/* background-color: transparent; */}
{/* text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; */}
{/* } */}
{/* `}</style> */}
<View
// onMouseLeave={() => setMouseMoved(false)}
{...css({
flexGrow: 1,
// @ts-ignore
// cursor: displayControls ? "unset" : "none",
bg: "black",
})}
>
<Video
source={{ uri: data?.link.direct }}
videoStyle={{ margin: "auto" }}
{...css(StyleSheet.absoluteFillObject)}
/* {...videoProps} */
// onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
// if (e.pointerType === "mouse") {
// onVideoClick();
// } else if (mouseMoved) {
// setMouseMoved(false);
// } else {
// mouseHasMoved();
// }
// }}
// onEnded={() => {
// if (!data) return;
// if (data.isMovie) router.push(`/movie/${data.slug}`);
// else
// router.push(
// data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : `/show/${data.showSlug}`,
// );
// }}
/>
{/* <LoadingIndicator /> */}
<Hover
{...mapData(data, previous, next)}
// onPointerOver={(e: ReactPointerEvent<HTMLElement>) => {
// if (e.pointerType === "mouse") setHover(true);
// }}
// onPointerOut={() => setHover(false)}
onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable.
setHover(false);
setMenuOpen(false);
}}
// sx={
// displayControls
// ? {
// visibility: "visible",
// opacity: 1,
// transition: "opacity .2s ease-in",
// }
// : {
// visibility: "hidden",
// opacity: 0,
// transition: "opacity .4s ease-out, visibility 0s .4s",
// }
// }
/>
</View>
</>
);
};
// Player.getFetchUrls = ({ slug }) => [query(slug)];

View File

@ -18,10 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Font, Track } from "@kyoo/models";
import { atom, useSetAtom } from "jotai"; import { atom, useSetAtom } from "jotai";
import { useRouter } from "next/router"; import { useRouter } from "solito/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { Font, Track } from "~/models/resources/watch-item";
import { import {
durationAtom, durationAtom,
fullscreenAtom, fullscreenAtom,

View File

@ -19,7 +19,7 @@
*/ */
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useRouter } from "next/router"; import { useRouter } from "solito/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { reducerAtom } from "./keyboard"; import { reducerAtom } from "./keyboard";
import { durationAtom, playAtom, progressAtom } from "./state"; import { durationAtom, playAtom, progressAtom } from "./state";

View File

@ -1,201 +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 { QueryIdentifier, QueryPage } from "~/utils/query";
import { withRoute } from "~/utils/router";
import { WatchItem, WatchItemP } from "~/models/resources/watch-item";
import { useFetch } from "~/utils/query";
import { ErrorPage } from "~/components/errors";
import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
import { Box } from "@mui/material";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Hover, LoadingIndicator } from "./components/hover";
import { fullscreenAtom, playAtom, useSubtitleController, useVideoController } from "./state";
import { useRouter } from "next/router";
import Head from "next/head";
import { makeTitle } from "~/utils/utils";
import { episodeDisplayNumber } from "~/components/episode";
import { useVideoKeyboard } from "./keyboard";
import { MediaSessionManager } from "./media-session";
// 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)
let mouseCallback: NodeJS.Timeout;
const query = (slug: string): QueryIdentifier<WatchItem> => ({
path: ["watch", slug],
parser: WatchItemP,
});
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(slug));
const { playerRef, videoProps, onVideoClick } = useVideoController(data?.link);
const setFullscreen = useSetAtom(fullscreenAtom);
const router = useRouter();
const [isPlaying, setPlay] = useAtom(playAtom);
const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false);
const [menuOpenned, setMenuOpen] = useState(false);
const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned;
const previous =
data && !data.isMovie && data.previousEpisode
? `/watch/${data.previousEpisode.slug}`
: undefined;
const next =
data && !data.isMovie && data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : undefined;
const mouseHasMoved = () => {
setMouseMoved(true);
if (mouseCallback) clearTimeout(mouseCallback);
mouseCallback = setTimeout(() => {
setMouseMoved(false);
}, 2500);
};
useEffect(() => {
const handler = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
mouseHasMoved();
};
document.addEventListener("pointermove", handler);
return () => document.removeEventListener("pointermove", handler);
});
useEffect(() => {
setPlay(true);
}, [slug, setPlay]);
useEffect(() => {
if (!/Mobi/i.test(window.navigator.userAgent)) return;
setFullscreen(true);
return () => setFullscreen(false);
}, [setFullscreen]);
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
useVideoKeyboard(data?.subtitles, data?.fonts, previous, next);
if (error) return <ErrorPage {...error} />;
return (
<>
{data && (
<Head>
<title>
{makeTitle(
data.isMovie
? data.name
: data.showTitle +
" " +
episodeDisplayNumber({
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
absoluteNumber: data.absoluteNumber,
}),
)}
</title>
<meta name="description" content={data.overview ?? undefined} />
</Head>
)}
<MediaSessionManager
title={data?.name}
image={data?.thumbnail}
next={next}
previous={previous}
/>
<style jsx global>{`
::cue {
background-color: transparent;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
}
`}</style>
<Box
onMouseLeave={() => setMouseMoved(false)}
sx={{ cursor: displayControls ? "unset" : "none" }}
>
<Box
component="video"
{...videoProps}
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
if (e.pointerType === "mouse") {
onVideoClick();
} else if (mouseMoved) {
setMouseMoved(false);
} else {
mouseHasMoved();
}
}}
onEnded={() => {
if (!data) return;
if (data.isMovie) router.push(`/movie/${data.slug}`);
else
router.push(
data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : `/show/${data.showSlug}`,
);
}}
sx={{
position: "fixed",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "black",
}}
/>
<LoadingIndicator />
<Hover
data={data}
onPointerOver={(e: ReactPointerEvent<HTMLElement>) => {
if (e.pointerType === "mouse") setHover(true);
}}
onPointerOut={() => setHover(false)}
onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable.
setHover(false);
setMenuOpen(false);
}}
sx={
displayControls
? {
visibility: "visible",
opacity: 1,
transition: "opacity .2s ease-in",
}
: {
visibility: "hidden",
opacity: 0,
transition: "opacity .4s ease-out, visibility 0s .4s",
}
}
/>
</Box>
</>
);
};
Player.getFetchUrls = ({ slug }) => [query(slug)];
export default withRoute(Player);

View File

@ -18,15 +18,14 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { BoxProps } from "@mui/material"; import { Font, Track } from "@kyoo/models";
import { atom, useAtom, useSetAtom } from "jotai"; import { atom, useAtom, useSetAtom } from "jotai";
import { useRouter } from "next/router";
import { RefObject, useEffect, useRef } from "react"; import { RefObject, useEffect, useRef } from "react";
import { Font, Track } from "~/models/resources/watch-item"; import { createParam } from "solito";
import { bakedAtom } from "~/utils/jotai-utils"; import { ResizeMode, VideoProps } from "expo-av";
// @ts-ignore import SubtitleOctopus from "libass-wasm";
import SubtitleOctopus from "@jellyfin/libass-wasm/dist/js/subtitles-octopus";
import Hls from "hls.js"; import Hls from "hls.js";
import { bakedAtom } from "../jotai-utils";
enum PlayMode { enum PlayMode {
Direct, Direct,
@ -104,69 +103,70 @@ export const useVideoController = (links?: { direct: string; transmux: string })
setPlayer(player); setPlayer(player);
useEffect(() => { // useEffect(() => {
if (!player.current) return; // if (!player.current) return;
setPlay(!player.current.paused); // setPlay(!player.current.paused);
}, [setPlay]); // }, [setPlay]);
useEffect(() => { // useEffect(() => {
setPlayMode(PlayMode.Direct); // setPlayMode(PlayMode.Direct);
}, [links, setPlayMode]); // }, [links, setPlayMode]);
useEffect(() => { // useEffect(() => {
const src = playMode === PlayMode.Direct ? links?.direct : links?.transmux; // const src = playMode === PlayMode.Direct ? links?.direct : links?.transmux;
if (!player?.current || !src) return; // if (!player?.current || !src) return;
if ( // if (
playMode == PlayMode.Direct || // playMode == PlayMode.Direct ||
player.current.canPlayType("application/vnd.apple.mpegurl") // player.current.canPlayType("application/vnd.apple.mpegurl")
) { // ) {
player.current.src = src; // player.current.src = src;
} else { // } else {
if (hls === null) hls = new Hls(); // if (hls === null) hls = new Hls();
hls.loadSource(src); // hls.loadSource(src);
hls.attachMedia(player.current); // hls.attachMedia(player.current);
hls.on(Hls.Events.MANIFEST_LOADED, async () => { // hls.on(Hls.Events.MANIFEST_LOADED, async () => {
try { // try {
await player.current?.play(); // await player.current?.play();
} catch {} // } catch {}
}); // });
} // }
}, [playMode, links, player]); // }, [playMode, links, player]);
useEffect(() => { // useEffect(() => {
if (!player?.current?.duration) return; // if (!player?.current?.duration) return;
setDuration(player.current.duration); // setDuration(player.current.duration);
}, [player, setDuration]); // }, [player, setDuration]);
const videoProps: BoxProps<"video"> = { const videoProps: VideoProps = {
ref: player, // ref: player,
onDoubleClick: () => { // shouldPlay: isPlaying,
setFullscreen(!document.fullscreenElement); // onDoubleClick: () => {
}, // setFullscreen(!document.fullscreenElement);
onPlay: () => setPlay(true), // },
onPause: () => setPlay(false), // onPlay: () => setPlay(true),
onWaiting: () => setLoad(true), // onPause: () => setPlay(false),
onCanPlay: () => setLoad(false), // onWaiting: () => setLoad(true),
// onCanPlay: () => setLoad(false),
onError: () => { onError: () => {
if (player?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) if (player?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
setPlayMode(PlayMode.Transmux); setPlayMode(PlayMode.Transmux);
}, },
onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0), // onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
onDurationChange: () => setDuration(player?.current?.duration ?? 0), // onDurationChange: () => setDuration(player?.current?.duration ?? 0),
onProgress: () => // onProgress: () =>
setBuffered( // setBuffered(
player?.current?.buffered.length // player?.current?.buffered.length
? player.current.buffered.end(player.current.buffered.length - 1) // ? player.current.buffered.end(player.current.buffered.length - 1)
: 0, // : 0,
), // ),
onVolumeChange: () => { // onVolumeChange: () => {
if (!player.current) return; // if (!player.current) return;
setVolume(player.current.volume * 100); // setVolume(player.current.volume * 100);
setMuted(player?.current.muted); // setMuted(player?.current.muted);
}, // },
autoPlay: true, resizeMode: ResizeMode.CONTAIN,
controls: false, useNativeControls: false,
}; };
return { return {
playerRef: player, playerRef: player,
@ -239,14 +239,14 @@ export const [_subtitleAtom, subtitleAtom] = bakedAtom<
} }
}); });
const { useParam } = createParam<{ subtitle: string }>();
export const useSubtitleController = ( export const useSubtitleController = (
player: RefObject<HTMLVideoElement>, player: RefObject<HTMLVideoElement>,
subtitles?: Track[], subtitles?: Track[],
fonts?: Font[], fonts?: Font[],
) => { ) => {
const { const [subtitle] = useParam("subtitle");
query: { subtitle },
} = useRouter();
const selectSubtitle = useSetAtom(subtitleAtom); const selectSubtitle = useSetAtom(subtitleAtom);
const newSub = subtitles?.find((x) => x.language === subtitle); const newSub = subtitles?.find((x) => x.language === subtitle);

View File

@ -6592,6 +6592,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"expo-av@npm:^13.0.2, expo-av@npm:~13.0.2":
version: 13.0.2
resolution: "expo-av@npm:13.0.2"
peerDependencies:
expo: "*"
checksum: ed929b4dce9ea2d70997fe33a2c5502850d58fed23c30548f99b7256d174d129eb61b193351e4948965f607c264fd229a4eb6cc3527a1a3d3f644593115f12fe
languageName: node
linkType: hard
"expo-constants@npm:~14.0.0, expo-constants@npm:~14.0.2": "expo-constants@npm:~14.0.0, expo-constants@npm:~14.0.2":
version: 14.0.2 version: 14.0.2
resolution: "expo-constants@npm:14.0.2" resolution: "expo-constants@npm:14.0.2"
@ -6702,6 +6711,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"expo-navigation-bar@npm:~2.0.1":
version: 2.0.1
resolution: "expo-navigation-bar@npm:2.0.1"
dependencies:
"@react-native/normalize-color": ^2.0.0
debug: ^4.3.2
peerDependencies:
expo: "*"
checksum: 147daf412dba4df90b47d7b9dfbf323e5c2c7a08c1f2fba69a8d2b56cd98310b74316e8ae0fc28ea378f997a5c188001a624f2939fcd471c4df6c3fe051b7430
languageName: node
linkType: hard
"expo-router@npm:^0.0.36": "expo-router@npm:^0.0.36":
version: 0.0.36 version: 0.0.36
resolution: "expo-router@npm:0.0.36" resolution: "expo-router@npm:0.0.36"
@ -6735,6 +6756,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"expo-screen-orientation@npm:~5.0.1":
version: 5.0.1
resolution: "expo-screen-orientation@npm:5.0.1"
peerDependencies:
expo: "*"
checksum: 7ede30533a8c492f82b58c3b8be110b6373ffcc2cbe273299d9f15d9aa943d678d8aaffb3d2565780b45d1d5a2a1ddea54d813fc84c06e30e3cfd59abbd8e30e
languageName: node
linkType: hard
"expo-splash-screen@npm:*": "expo-splash-screen@npm:*":
version: 0.17.5 version: 0.17.5
resolution: "expo-splash-screen@npm:0.17.5" resolution: "expo-splash-screen@npm:0.17.5"
@ -9962,11 +9992,14 @@ __metadata:
"@types/react-native": ~0.70.6 "@types/react-native": ~0.70.6
babel-plugin-transform-inline-environment-variables: ^0.4.4 babel-plugin-transform-inline-environment-variables: ^0.4.4
expo: ^47.0.0 expo: ^47.0.0
expo-av: ~13.0.2
expo-constants: ~14.0.2 expo-constants: ~14.0.2
expo-linear-gradient: ~12.0.1 expo-linear-gradient: ~12.0.1
expo-linking: ~3.2.3 expo-linking: ~3.2.3
expo-localization: ~14.0.0 expo-localization: ~14.0.0
expo-navigation-bar: ~2.0.1
expo-router: ^0.0.36 expo-router: ^0.0.36
expo-screen-orientation: ~5.0.1
expo-status-bar: ~1.4.2 expo-status-bar: ~1.4.2
i18next: ^22.0.6 i18next: ^22.0.6
intl-pluralrules: ^1.3.1 intl-pluralrules: ^1.3.1
@ -13625,6 +13658,7 @@ __metadata:
csstype: ^3.1.1 csstype: ^3.1.1
eslint: ^8.28.0 eslint: ^8.28.0
eslint-config-next: 13.0.5 eslint-config-next: 13.0.5
expo-av: ^13.0.2
expo-linear-gradient: ^12.0.1 expo-linear-gradient: ^12.0.1
hls.js: ^1.2.8 hls.js: ^1.2.8
i18next: ^22.0.6 i18next: ^22.0.6