mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -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 { 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",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
"expo": "^47.0.0",
|
||||
"expo-av": "~13.0.2",
|
||||
"expo-constants": "~14.0.2",
|
||||
"expo-linear-gradient": "~12.0.1",
|
||||
"expo-linking": "~3.2.3",
|
||||
"expo-localization": "~14.0.0",
|
||||
"expo-navigation-bar": "~2.0.1",
|
||||
"expo-router": "^0.0.36",
|
||||
"expo-screen-orientation": "~5.0.1",
|
||||
"expo-status-bar": "~1.4.2",
|
||||
"i18next": "^22.0.6",
|
||||
"intl-pluralrules": "^1.3.1",
|
||||
|
@ -19,19 +19,37 @@
|
||||
*/
|
||||
|
||||
import { Stack } from "expo-router";
|
||||
import { ComponentType } from "react";
|
||||
import { ComponentType, useEffect } from "react";
|
||||
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,>(
|
||||
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 }) => {
|
||||
return (
|
||||
<>
|
||||
{routeOptions && <Stack.Screen {...routeOptions} />}
|
||||
{statusBar && <StatusBar {...statusBar} />}
|
||||
{fullscreen && <FullscreenProvider />}
|
||||
<Component {...route.params} {...props} />
|
||||
</>
|
||||
);
|
||||
|
@ -23,7 +23,7 @@ const CopyPlugin = require("copy-webpack-plugin");
|
||||
const DefinePlugin = require("webpack").DefinePlugin;
|
||||
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}
|
||||
@ -115,6 +115,7 @@ const nextConfig = {
|
||||
"@expo/html-elements",
|
||||
"expo-font",
|
||||
"expo-asset",
|
||||
"expo-av",
|
||||
"expo-modules-core",
|
||||
"expo-linear-gradient",
|
||||
],
|
||||
|
@ -23,6 +23,7 @@
|
||||
"@tanstack/react-query": "^4.19.1",
|
||||
"clsx": "^1.2.1",
|
||||
"csstype": "^3.1.1",
|
||||
"expo-av": "^13.0.2",
|
||||
"expo-linear-gradient": "^12.0.1",
|
||||
"hls.js": "^1.2.8",
|
||||
"i18next": "^22.0.6",
|
||||
|
@ -43,6 +43,7 @@ html, body, #__next {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
|
@ -18,144 +18,178 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ArrowBack } from "@mui/icons-material";
|
||||
import {
|
||||
Box,
|
||||
BoxProps,
|
||||
CircularProgress,
|
||||
ContrastArea,
|
||||
H1,
|
||||
H2,
|
||||
IconButton,
|
||||
Link,
|
||||
Poster,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useTranslation from "next-translate/useTranslation";
|
||||
import NextLink from "next/link";
|
||||
import { Poster } from "~/components/poster";
|
||||
import { WatchItem } from "~/models/resources/watch-item";
|
||||
import { loadAtom } from "../state";
|
||||
import { episodeDisplayNumber } from "~/components/episode";
|
||||
tooltip,
|
||||
ts,
|
||||
} from "@kyoo/primitives";
|
||||
import { Chapter, Font, Track } from "@kyoo/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
|
||||
import { LeftButtons } from "./left-buttons";
|
||||
import { RightButtons } from "./right-buttons";
|
||||
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 = ({
|
||||
data,
|
||||
name,
|
||||
showName,
|
||||
href,
|
||||
poster,
|
||||
chapters,
|
||||
subtitles,
|
||||
fonts,
|
||||
previousSlug,
|
||||
nextSlug,
|
||||
onMenuOpen,
|
||||
onMenuClose,
|
||||
...props
|
||||
}: { data?: WatchItem; onMenuOpen: () => void; onMenuClose: () => void } & BoxProps) => {
|
||||
const name = data
|
||||
? data.isMovie
|
||||
? data.name
|
||||
: `${episodeDisplayNumber(data, "")} ${data.name}`
|
||||
: undefined;
|
||||
|
||||
}: {
|
||||
name?: string;
|
||||
showName?: string;
|
||||
href?: string;
|
||||
poster?: string | null;
|
||||
chapters?: Chapter[];
|
||||
subtitles?: Track[];
|
||||
fonts?: Font[];
|
||||
previousSlug?: string | null;
|
||||
nextSlug?: string | null;
|
||||
onMenuOpen: () => void;
|
||||
onMenuClose: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Box {...props}>
|
||||
<Back
|
||||
name={data?.name}
|
||||
href={data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#"}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "rgba(0, 0, 0, 0.6)",
|
||||
display: "flex",
|
||||
padding: "1%",
|
||||
}}
|
||||
>
|
||||
<VideoPoster poster={data?.poster} />
|
||||
<Box
|
||||
sx={{ width: "100%", ml: { xs: 0.5, sm: 3 }, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Typography variant="h4" component="h2" color="white" sx={{ pb: 1 }}>
|
||||
{name ?? <Skeleton />}
|
||||
</Typography>
|
||||
|
||||
<ProgressBar chapters={data?.chapters} />
|
||||
|
||||
<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
|
||||
subtitles={data?.subtitles}
|
||||
fonts={data?.fonts}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<ContrastArea mode="dark">
|
||||
{({ css }) => (
|
||||
<>
|
||||
<Back name={showName} href={href} />
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bg: "rgba(0, 0, 0, 0.6)",
|
||||
flexDirection: "row",
|
||||
padding: percent(1),
|
||||
})}
|
||||
>
|
||||
<VideoPoster poster={poster} />
|
||||
<View
|
||||
{...css({
|
||||
marginLeft: { xs: ts(0.5), sm: ts(3) },
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
})}
|
||||
>
|
||||
<H2 {...css({ paddingBottom: ts(1) })}>{name ?? <Skeleton variant="fill" />}</H2>
|
||||
<ProgressBar chapters={chapters} />
|
||||
<View
|
||||
{...css({ flexDirection: "row", flexGrow: 1, justifyContent: "space-between" })}
|
||||
>
|
||||
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
|
||||
<RightButtons
|
||||
subtitles={subtitles}
|
||||
fonts={fonts}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ContrastArea>
|
||||
);
|
||||
};
|
||||
export const Back = ({ name, href }: { name?: string; href: string }) => {
|
||||
const { t } = useTranslation("player");
|
||||
export const Back = ({ name, href }: { name?: string; href?: string }) => {
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "rgba(0, 0, 0, 0.6)",
|
||||
bg: "rgba(0, 0, 0, 0.6)",
|
||||
display: "flex",
|
||||
p: "0.33%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: percent(0.33),
|
||||
color: "white",
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<Tooltip title={t("back")}>
|
||||
<NextLink href={href} passHref>
|
||||
<IconButton aria-label={t("back")} sx={{ color: "white" }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
</NextLink>
|
||||
</Tooltip>
|
||||
<Typography component="h1" variant="h5" sx={{ alignSelf: "center", ml: "1rem" }}>
|
||||
{name ? name : <Skeleton />}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton icon={ArrowBack} as={Link} href={href ?? ""} {...tooltip(t("back"))} />
|
||||
<Skeleton>
|
||||
{name ? (
|
||||
<H1
|
||||
{...css({
|
||||
alignSelf: "center",
|
||||
marginBottom: 0,
|
||||
fontSize: rem(1.5),
|
||||
marginLeft: rem(1),
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</H1>
|
||||
) : (
|
||||
<Skeleton {...css({ width: rem(5), marginBottom: 0 })} />
|
||||
)}
|
||||
</Skeleton>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPoster = ({ poster }: { poster?: string | null }) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<View
|
||||
{...css({
|
||||
width: "15%",
|
||||
display: { xs: "none", sm: "block" },
|
||||
display: { xs: "none", sm: "flex" },
|
||||
position: "relative",
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<Poster img={poster} width="100%" sx={{ position: "absolute", bottom: 0 }} />
|
||||
</Box>
|
||||
<Poster
|
||||
src={poster}
|
||||
layout={{ width: percent(100) }}
|
||||
{...css({ position: "absolute", bottom: 0 })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadingIndicator = () => {
|
||||
const isLoading = useAtomValue(loadAtom);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "rgba(0, 0, 0, 0.3)",
|
||||
bg: "rgba(0, 0, 0, 0.3)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<CircularProgress thickness={5} sx={{ color: "white", alignSelf: "center" }} />
|
||||
</Box>
|
||||
<CircularProgress {...css({ alignSelf: "center" })} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
@ -18,109 +18,95 @@
|
||||
* 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 useTranslation from "next-translate/useTranslation";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 NextLink from "next/link";
|
||||
import {
|
||||
Pause,
|
||||
PlayArrow,
|
||||
SkipNext,
|
||||
SkipPrevious,
|
||||
VolumeDown,
|
||||
VolumeMute,
|
||||
VolumeOff,
|
||||
VolumeUp,
|
||||
} from "@mui/icons-material";
|
||||
import { useYoshiki } from "yoshiki/native";
|
||||
|
||||
export const LeftButtons = ({
|
||||
previousSlug,
|
||||
nextSlug,
|
||||
}: {
|
||||
previousSlug?: string;
|
||||
nextSlug?: string;
|
||||
previousSlug?: string | null;
|
||||
nextSlug?: string | null;
|
||||
}) => {
|
||||
const { t } = useTranslation("player");
|
||||
const router = useRouter();
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
|
||||
const spacing = css({ marginHorizontal: ts(1) });
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
"> *": {
|
||||
mx: { xs: "2px !important", sm: "8px !important" },
|
||||
p: { xs: "4px !important", sm: "8px !important" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<View {...css({ flexDirection: "row" })}>
|
||||
{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
|
||||
onClick={() => setPlay(!isPlaying)}
|
||||
aria-label={isPlaying ? t("pause") : t("play")}
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
{isPlaying ? <Pause /> : <PlayArrow />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
icon={SkipPrevious}
|
||||
as={Link}
|
||||
href={previousSlug}
|
||||
{...tooltip(t("player.previous"))}
|
||||
{...spacing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={isPlaying ? Pause : PlayArrow}
|
||||
onClick={() => setPlay(!isPlaying)}
|
||||
{...tooltip(isPlaying ? t("player.pause") : t("player.play"))}
|
||||
{...spacing}
|
||||
/>
|
||||
{nextSlug && (
|
||||
<Tooltip title={t("next")}>
|
||||
<NextLink href={{ query: { ...router.query, slug: nextSlug } }} passHref>
|
||||
<IconButton aria-label={t("next")} sx={{ color: "white" }}>
|
||||
<SkipNext />
|
||||
</IconButton>
|
||||
</NextLink>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={SkipNext}
|
||||
as={Link}
|
||||
href={nextSlug}
|
||||
{...tooltip(t("next"))}
|
||||
{...spacing}
|
||||
/>
|
||||
)}
|
||||
<VolumeSlider />
|
||||
<ProgressText />
|
||||
</Box>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const VolumeSlider = () => {
|
||||
const [volume, setVolume] = useAtom(volumeAtom);
|
||||
const [isMuted, setMuted] = useAtom(mutedAtom);
|
||||
const { t } = useTranslation("player");
|
||||
const { css } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return null;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<View
|
||||
{...css({
|
||||
display: { xs: "none", sm: "flex" },
|
||||
m: "0 !important",
|
||||
p: "8px",
|
||||
p: ts(1),
|
||||
"body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" },
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<Tooltip title={t("mute")}>
|
||||
<IconButton
|
||||
onClick={() => setMuted(!isMuted)}
|
||||
aria-label={t("mute")}
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
{isMuted || volume == 0 ? (
|
||||
<VolumeOff />
|
||||
) : volume < 25 ? (
|
||||
<VolumeMute />
|
||||
) : volume < 65 ? (
|
||||
<VolumeDown />
|
||||
) : (
|
||||
<VolumeUp />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Box
|
||||
<IconButton
|
||||
icon={
|
||||
isMuted || volume == 0
|
||||
? VolumeOff
|
||||
: volume < 25
|
||||
? VolumeMute
|
||||
: volume < 65
|
||||
? VolumeDown
|
||||
: VolumeUp
|
||||
}
|
||||
onClick={() => setMuted(!isMuted)}
|
||||
{...tooltip(t("mute"))}
|
||||
/>
|
||||
<View
|
||||
className="slider"
|
||||
sx={{
|
||||
width: 0,
|
||||
@ -136,19 +122,20 @@ const VolumeSlider = () => {
|
||||
aria-label={t("volume")}
|
||||
sx={{ alignSelf: "center" }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressText = () => {
|
||||
const progress = useAtomValue(progressAtom);
|
||||
const duration = useAtomValue(durationAtom);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
return (
|
||||
<Typography color="white" sx={{ alignSelf: "center" }}>
|
||||
<P {...css({ alignSelf: "center", marginBottom: 0 })}>
|
||||
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
||||
</Typography>
|
||||
</P>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -18,20 +18,24 @@
|
||||
* 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 { 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";
|
||||
|
||||
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 [progress, setProgress] = useAtom(progressAtom);
|
||||
const buffered = useAtomValue(bufferedAtom);
|
||||
const duration = useAtomValue(durationAtom);
|
||||
|
||||
const updateProgress = (event: MouseEvent | TouchEvent, skipSeek?: boolean) => {
|
||||
const updateProgress = (event: NativeTouchEvent, skipSeek?: boolean) => {
|
||||
if (!(isSeeking || skipSeek) || !ref?.current) return;
|
||||
const pageX: number = "pageX" in event ? event.pageX : event.changedTouches[0].pageX;
|
||||
const value: number = (pageX - ref.current.offsetLeft) / ref.current.clientWidth;
|
||||
@ -58,26 +62,25 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseDown={(event) => {
|
||||
<Pressable
|
||||
onPointerDown={(event) => {
|
||||
// prevent drag and drop of the UI.
|
||||
event.preventDefault();
|
||||
setSeek(true);
|
||||
}}
|
||||
onTouchStart={() => setSeek(true)}
|
||||
onClick={(event) => updateProgress(event.nativeEvent, true)}
|
||||
sx={{
|
||||
width: "100%",
|
||||
py: 1,
|
||||
onPress={(event) => updateProgress(event.nativeEvent, true)}
|
||||
{...css({
|
||||
width: percent(100),
|
||||
paddingVertical: ts(1),
|
||||
cursor: "pointer",
|
||||
WebkitTapHighlightColor: "transparent",
|
||||
"body.hoverEnabled &:hover": {
|
||||
".thumb": { opacity: 1 },
|
||||
".bar": { transform: "unset" },
|
||||
},
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
<View
|
||||
ref={ref}
|
||||
className="bar"
|
||||
sx={{
|
||||
@ -88,7 +91,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
<View
|
||||
sx={{
|
||||
width: `${(buffered / duration) * 100}%`,
|
||||
position: "absolute",
|
||||
@ -98,7 +101,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||
background: "rgba(255, 255, 255, 0.5)",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
<View
|
||||
sx={{
|
||||
width: `${(progress / duration) * 100}%`,
|
||||
position: "absolute",
|
||||
@ -108,7 +111,7 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||
background: (theme) => theme.palette.primary.main,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
<View
|
||||
className="thumb"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
@ -125,19 +128,19 @@ export const ProgressBar = ({ chapters }: { chapters?: Chapter[] }) => {
|
||||
/>
|
||||
|
||||
{chapters?.map((x) => (
|
||||
<Box
|
||||
<View
|
||||
key={x.startTime}
|
||||
sx={{
|
||||
{...css({
|
||||
position: "absolute",
|
||||
width: "4px",
|
||||
width: px(4),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `${Math.min(100, (x.startTime / duration) * 100)}%`,
|
||||
background: (theme) => theme.palette.primary.dark,
|
||||
}}
|
||||
bg: (theme) => theme.accent,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
@ -18,14 +18,15 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ClosedCaption, Fullscreen, FullscreenExit } from "@mui/icons-material";
|
||||
import { Box, IconButton, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material";
|
||||
import { Font, Track } from "@kyoo/models";
|
||||
import { IconButton, tooltip } from "@kyoo/primitives";
|
||||
import { useAtom } from "jotai";
|
||||
import useTranslation from "next-translate/useTranslation";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "solito/router";
|
||||
import { useState } from "react";
|
||||
import { Font, Track } from "~/models/resources/watch-item";
|
||||
import { Link } from "~/utils/link";
|
||||
import { View } from "react-native";
|
||||
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";
|
||||
|
||||
export const RightButtons = ({
|
||||
@ -39,59 +40,56 @@ export const RightButtons = ({
|
||||
onMenuOpen: () => void;
|
||||
onMenuClose: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation("player");
|
||||
const { t } = useTranslation();
|
||||
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
"> *": {
|
||||
m: { xs: "4px !important", sm: "8px !important" },
|
||||
p: { xs: "4px !important", sm: "8px !important" },
|
||||
},
|
||||
}}
|
||||
<View
|
||||
// sx={{
|
||||
// display: "flex",
|
||||
// "> *": {
|
||||
// m: { xs: "4px !important", sm: "8px !important" },
|
||||
// p: { xs: "4px !important", sm: "8px !important" },
|
||||
// },
|
||||
// }}
|
||||
>
|
||||
{subtitles && (
|
||||
<Tooltip title={t("subtitles")}>
|
||||
<IconButton
|
||||
id="sortby"
|
||||
aria-label={t("subtitles")}
|
||||
aria-controls={subtitleAnchor ? "subtitle-menu" : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={subtitleAnchor ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
setSubtitleAnchor(event.currentTarget);
|
||||
onMenuOpen();
|
||||
}}
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
<ClosedCaption />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t("fullscreen")}>
|
||||
<IconButton
|
||||
onClick={() => setFullscreen(!isFullscreen)}
|
||||
aria-label={t("fullscreen")}
|
||||
sx={{ color: "white" }}
|
||||
>
|
||||
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{subtitleAnchor && (
|
||||
<SubtitleMenu
|
||||
subtitles={subtitles!}
|
||||
fonts={fonts!}
|
||||
anchor={subtitleAnchor}
|
||||
onClose={() => {
|
||||
setSubtitleAnchor(null);
|
||||
onMenuClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{/* {subtitles && ( */}
|
||||
{/* <Tooltip title={t("subtitles")}> */}
|
||||
{/* <IconButton */}
|
||||
{/* id="sortby" */}
|
||||
{/* aria-label={t("subtitles")} */}
|
||||
{/* aria-controls={subtitleAnchor ? "subtitle-menu" : undefined} */}
|
||||
{/* aria-haspopup="true" */}
|
||||
{/* aria-expanded={subtitleAnchor ? "true" : undefined} */}
|
||||
{/* onClick={(event) => { */}
|
||||
{/* setSubtitleAnchor(event.currentTarget); */}
|
||||
{/* onMenuOpen(); */}
|
||||
{/* }} */}
|
||||
{/* sx={{ color: "white" }} */}
|
||||
{/* > */}
|
||||
{/* <ClosedCaption /> */}
|
||||
{/* </IconButton> */}
|
||||
{/* </Tooltip> */}
|
||||
{/* )} */}
|
||||
<IconButton
|
||||
icon={isFullscreen ? FullscreenExit : Fullscreen}
|
||||
onClick={() => setFullscreen(!isFullscreen)}
|
||||
{...tooltip(t("fullscreen"))}
|
||||
sx={{ color: "white" }}
|
||||
/>
|
||||
{/* {subtitleAnchor && ( */}
|
||||
{/* <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/>.
|
||||
*/
|
||||
|
||||
import { Font, Track } from "@kyoo/models";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "solito/router";
|
||||
import { useEffect } from "react";
|
||||
import { Font, Track } from "~/models/resources/watch-item";
|
||||
import {
|
||||
durationAtom,
|
||||
fullscreenAtom,
|
||||
|
@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "solito/router";
|
||||
import { useEffect } from "react";
|
||||
import { reducerAtom } from "./keyboard";
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { BoxProps } from "@mui/material";
|
||||
import { Font, Track } from "@kyoo/models";
|
||||
import { atom, useAtom, useSetAtom } from "jotai";
|
||||
import { useRouter } from "next/router";
|
||||
import { RefObject, useEffect, useRef } from "react";
|
||||
import { Font, Track } from "~/models/resources/watch-item";
|
||||
import { bakedAtom } from "~/utils/jotai-utils";
|
||||
// @ts-ignore
|
||||
import SubtitleOctopus from "@jellyfin/libass-wasm/dist/js/subtitles-octopus";
|
||||
import { createParam } from "solito";
|
||||
import { ResizeMode, VideoProps } from "expo-av";
|
||||
import SubtitleOctopus from "libass-wasm";
|
||||
import Hls from "hls.js";
|
||||
import { bakedAtom } from "../jotai-utils";
|
||||
|
||||
enum PlayMode {
|
||||
Direct,
|
||||
@ -104,69 +103,70 @@ export const useVideoController = (links?: { direct: string; transmux: string })
|
||||
|
||||
setPlayer(player);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player.current) return;
|
||||
setPlay(!player.current.paused);
|
||||
}, [setPlay]);
|
||||
// useEffect(() => {
|
||||
// if (!player.current) return;
|
||||
// setPlay(!player.current.paused);
|
||||
// }, [setPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlayMode(PlayMode.Direct);
|
||||
}, [links, setPlayMode]);
|
||||
// useEffect(() => {
|
||||
// setPlayMode(PlayMode.Direct);
|
||||
// }, [links, setPlayMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const src = playMode === PlayMode.Direct ? links?.direct : links?.transmux;
|
||||
// useEffect(() => {
|
||||
// const src = playMode === PlayMode.Direct ? links?.direct : links?.transmux;
|
||||
|
||||
if (!player?.current || !src) return;
|
||||
if (
|
||||
playMode == PlayMode.Direct ||
|
||||
player.current.canPlayType("application/vnd.apple.mpegurl")
|
||||
) {
|
||||
player.current.src = src;
|
||||
} else {
|
||||
if (hls === null) hls = new Hls();
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(player.current);
|
||||
hls.on(Hls.Events.MANIFEST_LOADED, async () => {
|
||||
try {
|
||||
await player.current?.play();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
}, [playMode, links, player]);
|
||||
// if (!player?.current || !src) return;
|
||||
// if (
|
||||
// playMode == PlayMode.Direct ||
|
||||
// player.current.canPlayType("application/vnd.apple.mpegurl")
|
||||
// ) {
|
||||
// player.current.src = src;
|
||||
// } else {
|
||||
// if (hls === null) hls = new Hls();
|
||||
// hls.loadSource(src);
|
||||
// hls.attachMedia(player.current);
|
||||
// hls.on(Hls.Events.MANIFEST_LOADED, async () => {
|
||||
// try {
|
||||
// await player.current?.play();
|
||||
// } catch {}
|
||||
// });
|
||||
// }
|
||||
// }, [playMode, links, player]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player?.current?.duration) return;
|
||||
setDuration(player.current.duration);
|
||||
}, [player, setDuration]);
|
||||
// useEffect(() => {
|
||||
// if (!player?.current?.duration) return;
|
||||
// setDuration(player.current.duration);
|
||||
// }, [player, setDuration]);
|
||||
|
||||
const videoProps: BoxProps<"video"> = {
|
||||
ref: player,
|
||||
onDoubleClick: () => {
|
||||
setFullscreen(!document.fullscreenElement);
|
||||
},
|
||||
onPlay: () => setPlay(true),
|
||||
onPause: () => setPlay(false),
|
||||
onWaiting: () => setLoad(true),
|
||||
onCanPlay: () => setLoad(false),
|
||||
const videoProps: VideoProps = {
|
||||
// ref: player,
|
||||
// shouldPlay: isPlaying,
|
||||
// onDoubleClick: () => {
|
||||
// setFullscreen(!document.fullscreenElement);
|
||||
// },
|
||||
// onPlay: () => setPlay(true),
|
||||
// onPause: () => setPlay(false),
|
||||
// onWaiting: () => setLoad(true),
|
||||
// onCanPlay: () => setLoad(false),
|
||||
onError: () => {
|
||||
if (player?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
|
||||
setPlayMode(PlayMode.Transmux);
|
||||
},
|
||||
onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
|
||||
onDurationChange: () => setDuration(player?.current?.duration ?? 0),
|
||||
onProgress: () =>
|
||||
setBuffered(
|
||||
player?.current?.buffered.length
|
||||
? player.current.buffered.end(player.current.buffered.length - 1)
|
||||
: 0,
|
||||
),
|
||||
onVolumeChange: () => {
|
||||
if (!player.current) return;
|
||||
setVolume(player.current.volume * 100);
|
||||
setMuted(player?.current.muted);
|
||||
},
|
||||
autoPlay: true,
|
||||
controls: false,
|
||||
// onTimeUpdate: () => setProgress(player?.current?.currentTime ?? 0),
|
||||
// onDurationChange: () => setDuration(player?.current?.duration ?? 0),
|
||||
// onProgress: () =>
|
||||
// setBuffered(
|
||||
// player?.current?.buffered.length
|
||||
// ? player.current.buffered.end(player.current.buffered.length - 1)
|
||||
// : 0,
|
||||
// ),
|
||||
// onVolumeChange: () => {
|
||||
// if (!player.current) return;
|
||||
// setVolume(player.current.volume * 100);
|
||||
// setMuted(player?.current.muted);
|
||||
// },
|
||||
resizeMode: ResizeMode.CONTAIN,
|
||||
useNativeControls: false,
|
||||
};
|
||||
return {
|
||||
playerRef: player,
|
||||
@ -239,14 +239,14 @@ export const [_subtitleAtom, subtitleAtom] = bakedAtom<
|
||||
}
|
||||
});
|
||||
|
||||
const { useParam } = createParam<{ subtitle: string }>();
|
||||
|
||||
export const useSubtitleController = (
|
||||
player: RefObject<HTMLVideoElement>,
|
||||
subtitles?: Track[],
|
||||
fonts?: Font[],
|
||||
) => {
|
||||
const {
|
||||
query: { subtitle },
|
||||
} = useRouter();
|
||||
const [subtitle] = useParam("subtitle");
|
||||
const selectSubtitle = useSetAtom(subtitleAtom);
|
||||
|
||||
const newSub = subtitles?.find((x) => x.language === subtitle);
|
||||
|
@ -6592,6 +6592,15 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 14.0.2
|
||||
resolution: "expo-constants@npm:14.0.2"
|
||||
@ -6702,6 +6711,18 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 0.0.36
|
||||
resolution: "expo-router@npm:0.0.36"
|
||||
@ -6735,6 +6756,15 @@ __metadata:
|
||||
languageName: node
|
||||
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:*":
|
||||
version: 0.17.5
|
||||
resolution: "expo-splash-screen@npm:0.17.5"
|
||||
@ -9962,11 +9992,14 @@ __metadata:
|
||||
"@types/react-native": ~0.70.6
|
||||
babel-plugin-transform-inline-environment-variables: ^0.4.4
|
||||
expo: ^47.0.0
|
||||
expo-av: ~13.0.2
|
||||
expo-constants: ~14.0.2
|
||||
expo-linear-gradient: ~12.0.1
|
||||
expo-linking: ~3.2.3
|
||||
expo-localization: ~14.0.0
|
||||
expo-navigation-bar: ~2.0.1
|
||||
expo-router: ^0.0.36
|
||||
expo-screen-orientation: ~5.0.1
|
||||
expo-status-bar: ~1.4.2
|
||||
i18next: ^22.0.6
|
||||
intl-pluralrules: ^1.3.1
|
||||
@ -13625,6 +13658,7 @@ __metadata:
|
||||
csstype: ^3.1.1
|
||||
eslint: ^8.28.0
|
||||
eslint-config-next: 13.0.5
|
||||
expo-av: ^13.0.2
|
||||
expo-linear-gradient: ^12.0.1
|
||||
hls.js: ^1.2.8
|
||||
i18next: ^22.0.6
|
||||
|
Loading…
x
Reference in New Issue
Block a user