mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-06-23 15:30:34 -04:00
Rework of the hover
This commit is contained in:
parent
4f5023f745
commit
3c447f5708
@ -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,
|
||||||
|
});
|
||||||
|
@ -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",
|
||||||
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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",
|
||||||
],
|
],
|
||||||
|
@ -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",
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
209
front/packages/ui/src/player/index.tsx
Normal file
209
front/packages/ui/src/player/index.tsx
Normal 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)];
|
@ -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,
|
||||||
|
@ -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";
|
||||||
|
@ -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);
|
|
@ -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);
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user