Rework of the hover

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

View File

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

View File

@ -15,11 +15,14 @@
"@tanstack/react-query": "^4.19.1",
"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",

View File

@ -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} />
</>
);

View File

@ -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",
],

View File

@ -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",

View File

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

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,209 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models";
import { Head } from "@kyoo/primitives";
import { useState, useEffect, PointerEvent as ReactPointerEvent, ComponentProps } from "react";
import { StyleSheet, View } from "react-native";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useRouter } from "solito/router";
import { Video } from "expo-av";
import { percent, useYoshiki } from "yoshiki/native";
import { Hover, LoadingIndicator } from "./components/hover";
import { fullscreenAtom, playAtom, useSubtitleController, useVideoController } from "./state";
import { episodeDisplayNumber } from "../details/episode";
import { useVideoKeyboard } from "./keyboard";
import { MediaSessionManager } from "./media-session";
import { ErrorView } from "../fetch";
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
let mouseCallback: NodeJS.Timeout;
const query = (slug: string): QueryIdentifier<WatchItem> => ({
path: ["watch", slug],
parser: WatchItemP,
});
const mapData = (
data: WatchItem | undefined,
previousSlug: string,
nextSlug?: string,
): Partial<ComponentProps<typeof Hover>> => {
if (!data) return {};
return {
name: data.isMovie ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`,
showName: data.isMovie ? data.name : data.showTitle,
href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#",
poster: data.poster,
subtitles: data.subtitles,
chapters: data.chapters,
fonts: data.fonts,
previousSlug,
nextSlug,
};
};
export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { css } = useYoshiki();
const { data, error } = useFetch(query(slug));
const previous =
data && !data.isMovie && data.previousEpisode
? `/watch/${data.previousEpisode.slug}`
: undefined;
const next =
data && !data.isMovie && data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : undefined;
// const { playerRef, videoProps, onVideoClick } = useVideoController(data?.link);
// useSubtitleController(playerRef, data?.subtitles, data?.fonts);
// useVideoKeyboard(data?.subtitles, data?.fonts, previous, next);
const router = useRouter();
const setFullscreen = useSetAtom(fullscreenAtom);
const [isPlaying, setPlay] = useAtom(playAtom);
const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false);
const [menuOpenned, setMenuOpen] = useState(false);
const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned;
// const mouseHasMoved = () => {
// setMouseMoved(true);
// if (mouseCallback) clearTimeout(mouseCallback);
// mouseCallback = setTimeout(() => {
// setMouseMoved(false);
// }, 2500);
// };
// useEffect(() => {
// const handler = (e: PointerEvent) => {
// if (e.pointerType !== "mouse") return;
// mouseHasMoved();
// };
// document.addEventListener("pointermove", handler);
// return () => document.removeEventListener("pointermove", handler);
// });
// useEffect(() => {
// setPlay(true);
// }, [slug, setPlay]);
// useEffect(() => {
// if (!/Mobi/i.test(window.navigator.userAgent)) return;
// setFullscreen(true);
// return () => setFullscreen(false);
// }, [setFullscreen]);
if (error) return <ErrorView error={error} />;
return (
<>
{data && (
<Head
title={
data.isMovie
? data.name
: data.showTitle +
" " +
episodeDisplayNumber({
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
absoluteNumber: data.absoluteNumber,
})
}
description={data.overview}
/>
)}
<MediaSessionManager
title={data?.name}
image={data?.thumbnail}
next={next}
previous={previous}
/>
{/* <style jsx global>{` */}
{/* ::cue { */}
{/* background-color: transparent; */}
{/* text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; */}
{/* } */}
{/* `}</style> */}
<View
// onMouseLeave={() => setMouseMoved(false)}
{...css({
flexGrow: 1,
// @ts-ignore
// cursor: displayControls ? "unset" : "none",
bg: "black",
})}
>
<Video
source={{ uri: data?.link.direct }}
videoStyle={{ margin: "auto" }}
{...css(StyleSheet.absoluteFillObject)}
/* {...videoProps} */
// onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
// if (e.pointerType === "mouse") {
// onVideoClick();
// } else if (mouseMoved) {
// setMouseMoved(false);
// } else {
// mouseHasMoved();
// }
// }}
// onEnded={() => {
// if (!data) return;
// if (data.isMovie) router.push(`/movie/${data.slug}`);
// else
// router.push(
// data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : `/show/${data.showSlug}`,
// );
// }}
/>
{/* <LoadingIndicator /> */}
<Hover
{...mapData(data, previous, next)}
// onPointerOver={(e: ReactPointerEvent<HTMLElement>) => {
// if (e.pointerType === "mouse") setHover(true);
// }}
// onPointerOut={() => setHover(false)}
onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable.
setHover(false);
setMenuOpen(false);
}}
// sx={
// displayControls
// ? {
// visibility: "visible",
// opacity: 1,
// transition: "opacity .2s ease-in",
// }
// : {
// visibility: "hidden",
// opacity: 0,
// transition: "opacity .4s ease-out, visibility 0s .4s",
// }
// }
/>
</View>
</>
);
};
// Player.getFetchUrls = ({ slug }) => [query(slug)];

View File

@ -18,10 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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,

View File

@ -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";

View File

@ -1,201 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { QueryIdentifier, QueryPage } from "~/utils/query";
import { withRoute } from "~/utils/router";
import { WatchItem, WatchItemP } from "~/models/resources/watch-item";
import { useFetch } from "~/utils/query";
import { ErrorPage } from "~/components/errors";
import { useState, useEffect, PointerEvent as ReactPointerEvent } from "react";
import { Box } from "@mui/material";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Hover, LoadingIndicator } from "./components/hover";
import { fullscreenAtom, playAtom, useSubtitleController, useVideoController } from "./state";
import { useRouter } from "next/router";
import Head from "next/head";
import { makeTitle } from "~/utils/utils";
import { episodeDisplayNumber } from "~/components/episode";
import { useVideoKeyboard } from "./keyboard";
import { MediaSessionManager } from "./media-session";
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
let mouseCallback: NodeJS.Timeout;
const query = (slug: string): QueryIdentifier<WatchItem> => ({
path: ["watch", slug],
parser: WatchItemP,
});
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(slug));
const { playerRef, videoProps, onVideoClick } = useVideoController(data?.link);
const setFullscreen = useSetAtom(fullscreenAtom);
const router = useRouter();
const [isPlaying, setPlay] = useAtom(playAtom);
const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false);
const [menuOpenned, setMenuOpen] = useState(false);
const displayControls = showHover || !isPlaying || mouseMoved || menuOpenned;
const previous =
data && !data.isMovie && data.previousEpisode
? `/watch/${data.previousEpisode.slug}`
: undefined;
const next =
data && !data.isMovie && data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : undefined;
const mouseHasMoved = () => {
setMouseMoved(true);
if (mouseCallback) clearTimeout(mouseCallback);
mouseCallback = setTimeout(() => {
setMouseMoved(false);
}, 2500);
};
useEffect(() => {
const handler = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
mouseHasMoved();
};
document.addEventListener("pointermove", handler);
return () => document.removeEventListener("pointermove", handler);
});
useEffect(() => {
setPlay(true);
}, [slug, setPlay]);
useEffect(() => {
if (!/Mobi/i.test(window.navigator.userAgent)) return;
setFullscreen(true);
return () => setFullscreen(false);
}, [setFullscreen]);
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
useVideoKeyboard(data?.subtitles, data?.fonts, previous, next);
if (error) return <ErrorPage {...error} />;
return (
<>
{data && (
<Head>
<title>
{makeTitle(
data.isMovie
? data.name
: data.showTitle +
" " +
episodeDisplayNumber({
seasonNumber: data.seasonNumber,
episodeNumber: data.episodeNumber,
absoluteNumber: data.absoluteNumber,
}),
)}
</title>
<meta name="description" content={data.overview ?? undefined} />
</Head>
)}
<MediaSessionManager
title={data?.name}
image={data?.thumbnail}
next={next}
previous={previous}
/>
<style jsx global>{`
::cue {
background-color: transparent;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
}
`}</style>
<Box
onMouseLeave={() => setMouseMoved(false)}
sx={{ cursor: displayControls ? "unset" : "none" }}
>
<Box
component="video"
{...videoProps}
onPointerDown={(e: ReactPointerEvent<HTMLVideoElement>) => {
if (e.pointerType === "mouse") {
onVideoClick();
} else if (mouseMoved) {
setMouseMoved(false);
} else {
mouseHasMoved();
}
}}
onEnded={() => {
if (!data) return;
if (data.isMovie) router.push(`/movie/${data.slug}`);
else
router.push(
data.nextEpisode ? `/watch/${data.nextEpisode.slug}` : `/show/${data.showSlug}`,
);
}}
sx={{
position: "fixed",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "black",
}}
/>
<LoadingIndicator />
<Hover
data={data}
onPointerOver={(e: ReactPointerEvent<HTMLElement>) => {
if (e.pointerType === "mouse") setHover(true);
}}
onPointerOut={() => setHover(false)}
onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable.
setHover(false);
setMenuOpen(false);
}}
sx={
displayControls
? {
visibility: "visible",
opacity: 1,
transition: "opacity .2s ease-in",
}
: {
visibility: "hidden",
opacity: 0,
transition: "opacity .4s ease-out, visibility 0s .4s",
}
}
/>
</Box>
</>
);
};
Player.getFetchUrls = ({ slug }) => [query(slug)];
export default withRoute(Player);

View File

@ -18,15 +18,14 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
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);

View File

@ -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