diff --git a/front/apps/mobile/app/watch/[slug].tsx b/front/apps/mobile/app/watch/[slug].tsx index 213b397c..ca211966 100644 --- a/front/apps/mobile/app/watch/[slug].tsx +++ b/front/apps/mobile/app/watch/[slug].tsx @@ -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, +}); diff --git a/front/apps/mobile/package.json b/front/apps/mobile/package.json index 7c4edb93..d46a9112 100644 --- a/front/apps/mobile/package.json +++ b/front/apps/mobile/package.json @@ -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", diff --git a/front/apps/mobile/utils.tsx b/front/apps/mobile/utils.tsx index a13560c1..408e0067 100644 --- a/front/apps/mobile/utils.tsx +++ b/front/apps/mobile/utils.tsx @@ -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 = ( Component: ComponentType, - options?: Parameters[0] & { statusBar?: StatusBarProps }, + options?: Parameters[0] & { + statusBar?: StatusBarProps; + fullscreen?: boolean; + }, ) => { - const { statusBar, ...routeOptions } = options ?? {}; + const { statusBar, fullscreen, ...routeOptions } = options ?? {}; const WithUseRoute = ({ route, ...props }: Props & { route: any }) => { return ( <> {routeOptions && } {statusBar && } + {fullscreen && } ); diff --git a/front/apps/web/next.config.js b/front/apps/web/next.config.js index 24d9b6f0..e60bb5c8 100755 --- a/front/apps/web/next.config.js +++ b/front/apps/web/next.config.js @@ -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", ], diff --git a/front/apps/web/package.json b/front/apps/web/package.json index 9cc36393..ae1ecfbb 100644 --- a/front/apps/web/package.json +++ b/front/apps/web/package.json @@ -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", diff --git a/front/apps/web/src/pages/_document.tsx b/front/apps/web/src/pages/_document.tsx index f721de59..68ef3fc9 100644 --- a/front/apps/web/src/pages/_document.tsx +++ b/front/apps/web/src/pages/_document.tsx @@ -43,6 +43,7 @@ html, body, #__next { flex-grow: 1; display: flex; flex: 1; + overflow: hidden; } html { scroll-behavior: smooth; diff --git a/front/apps/web/src/utils/jotai-utils.tsx b/front/packages/ui/src/jotai-utils.tsx similarity index 100% rename from front/apps/web/src/utils/jotai-utils.tsx rename to front/packages/ui/src/jotai-utils.tsx diff --git a/front/packages/ui/src/player/components/hover.tsx b/front/packages/ui/src/player/components/hover.tsx index f4d999ea..8002ed23 100644 --- a/front/packages/ui/src/player/components/hover.tsx +++ b/front/packages/ui/src/player/components/hover.tsx @@ -18,144 +18,178 @@ * along with Kyoo. If not, see . */ -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 ( - - - - - - - {name ?? } - - - - - - - - - - - + + {({ css }) => ( + <> + + + + +

{name ?? }

+ + + + + +
+
+ + )} +
); }; -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 ( - - - - - - - - - - {name ? name : } - - + + + {name ? ( +

+ {name} +

+ ) : ( + + )} + + ); }; const VideoPoster = ({ poster }: { poster?: string | null }) => { + const { css } = useYoshiki(); + return ( - - - + + ); }; export const LoadingIndicator = () => { const isLoading = useAtomValue(loadAtom); + const { css } = useYoshiki(); + if (!isLoading) return null; + return ( - - - + + ); }; diff --git a/front/packages/ui/src/player/components/left-buttons.tsx b/front/packages/ui/src/player/components/left-buttons.tsx index 10ada8d3..a687826c 100644 --- a/front/packages/ui/src/player/components/left-buttons.tsx +++ b/front/packages/ui/src/player/components/left-buttons.tsx @@ -18,109 +18,95 @@ * along with Kyoo. If not, see . */ -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 ( - *": { - mx: { xs: "2px !important", sm: "8px !important" }, - p: { xs: "4px !important", sm: "8px !important" }, - }, - }} - > + {previousSlug && ( - - - - - - - - )} - setPlay(!isPlaying)} - aria-label={isPlaying ? t("pause") : t("play")} - sx={{ color: "white" }} - > - {isPlaying ? : } - - + icon={SkipPrevious} + as={Link} + href={previousSlug} + {...tooltip(t("player.previous"))} + {...spacing} + /> + )} + setPlay(!isPlaying)} + {...tooltip(isPlaying ? t("player.pause") : t("player.play"))} + {...spacing} + /> {nextSlug && ( - - - - - - - + )} - + ); }; 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 ( - - - setMuted(!isMuted)} - aria-label={t("mute")} - sx={{ color: "white" }} - > - {isMuted || volume == 0 ? ( - - ) : volume < 25 ? ( - - ) : volume < 65 ? ( - - ) : ( - - )} - - - setMuted(!isMuted)} + {...tooltip(t("mute"))} + /> + { aria-label={t("volume")} sx={{ alignSelf: "center" }} /> - - + + ); }; const ProgressText = () => { const progress = useAtomValue(progressAtom); const duration = useAtomValue(durationAtom); + const { css } = useYoshiki(); return ( - +

{toTimerString(progress, duration)} : {toTimerString(duration)} - +

); }; diff --git a/front/packages/ui/src/player/components/progress-bar.tsx b/front/packages/ui/src/player/components/progress-bar.tsx index a3866661..b5bb7c24 100644 --- a/front/packages/ui/src/player/components/progress-bar.tsx +++ b/front/packages/ui/src/player/components/progress-bar.tsx @@ -18,20 +18,24 @@ * along with Kyoo. If not, see . */ -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(null); + return null; + const { css } = useYoshiki(); + const ref = useRef(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 ( - { + { // 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" }, }, - }} + })} > - { position: "relative", }} > - { background: "rgba(255, 255, 255, 0.5)", }} /> - { background: (theme) => theme.palette.primary.main, }} /> - { /> {chapters?.map((x) => ( - theme.palette.primary.dark, - }} + bg: (theme) => theme.accent, + })} /> ))} - - + + ); }; diff --git a/front/packages/ui/src/player/components/right-buttons.tsx b/front/packages/ui/src/player/components/right-buttons.tsx index f2c93908..6305bde8 100644 --- a/front/packages/ui/src/player/components/right-buttons.tsx +++ b/front/packages/ui/src/player/components/right-buttons.tsx @@ -18,14 +18,15 @@ * along with Kyoo. If not, see . */ -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(null); const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); return ( - *": { - m: { xs: "4px !important", sm: "8px !important" }, - p: { xs: "4px !important", sm: "8px !important" }, - }, - }} + *": { + // m: { xs: "4px !important", sm: "8px !important" }, + // p: { xs: "4px !important", sm: "8px !important" }, + // }, + // }} > - {subtitles && ( - - { - setSubtitleAnchor(event.currentTarget); - onMenuOpen(); - }} - sx={{ color: "white" }} - > - - - - )} - - setFullscreen(!isFullscreen)} - aria-label={t("fullscreen")} - sx={{ color: "white" }} - > - {isFullscreen ? : } - - - {subtitleAnchor && ( - { - setSubtitleAnchor(null); - onMenuClose(); - }} - /> - )} - + {/* {subtitles && ( */} + {/* */} + {/* { */} + {/* setSubtitleAnchor(event.currentTarget); */} + {/* onMenuOpen(); */} + {/* }} */} + {/* sx={{ color: "white" }} */} + {/* > */} + {/* */} + {/* */} + {/* */} + {/* )} */} + setFullscreen(!isFullscreen)} + {...tooltip(t("fullscreen"))} + sx={{ color: "white" }} + /> + {/* {subtitleAnchor && ( */} + {/* { */} + {/* setSubtitleAnchor(null); */} + {/* onMenuClose(); */} + {/* }} */} + {/* /> */} + {/* )} */} + ); }; diff --git a/front/packages/ui/src/player/index.tsx b/front/packages/ui/src/player/index.tsx new file mode 100644 index 00000000..614c9f3d --- /dev/null +++ b/front/packages/ui/src/player/index.tsx @@ -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 . + */ + +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 => ({ + path: ["watch", slug], + parser: WatchItemP, +}); + +const mapData = ( + data: WatchItem | undefined, + previousSlug: string, + nextSlug?: string, +): Partial> => { + 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 ; + + return ( + <> + {data && ( + + )} + + {/* */} + setMouseMoved(false)} + {...css({ + flexGrow: 1, + // @ts-ignore + // cursor: displayControls ? "unset" : "none", + bg: "black", + })} + > + + + ); +}; + +// Player.getFetchUrls = ({ slug }) => [query(slug)]; diff --git a/front/packages/ui/src/player/keyboard.tsx b/front/packages/ui/src/player/keyboard.tsx index f26bb526..705dc128 100644 --- a/front/packages/ui/src/player/keyboard.tsx +++ b/front/packages/ui/src/player/keyboard.tsx @@ -18,10 +18,10 @@ * along with Kyoo. If not, see . */ +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, diff --git a/front/packages/ui/src/player/media-session.tsx b/front/packages/ui/src/player/media-session.tsx index 5f1bc02d..ee0d18fc 100644 --- a/front/packages/ui/src/player/media-session.tsx +++ b/front/packages/ui/src/player/media-session.tsx @@ -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"; diff --git a/front/packages/ui/src/player/player.tsx b/front/packages/ui/src/player/player.tsx deleted file mode 100644 index cbfbc473..00000000 --- a/front/packages/ui/src/player/player.tsx +++ /dev/null @@ -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 . - */ - -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 => ({ - 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 ; - - return ( - <> - {data && ( - - - {makeTitle( - data.isMovie - ? data.name - : data.showTitle + - " " + - episodeDisplayNumber({ - seasonNumber: data.seasonNumber, - episodeNumber: data.episodeNumber, - absoluteNumber: data.absoluteNumber, - }), - )} - - - - )} - - - setMouseMoved(false)} - sx={{ cursor: displayControls ? "unset" : "none" }} - > - ) => { - 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", - }} - /> - - ) => { - 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", - } - } - /> - - - ); -}; - -Player.getFetchUrls = ({ slug }) => [query(slug)]; - -export default withRoute(Player); diff --git a/front/packages/ui/src/player/state.tsx b/front/packages/ui/src/player/state.tsx index 2d0a0b43..6262e7f0 100644 --- a/front/packages/ui/src/player/state.tsx +++ b/front/packages/ui/src/player/state.tsx @@ -18,15 +18,14 @@ * along with Kyoo. If not, see . */ -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, subtitles?: Track[], fonts?: Font[], ) => { - const { - query: { subtitle }, - } = useRouter(); + const [subtitle] = useParam("subtitle"); const selectSubtitle = useSetAtom(subtitleAtom); const newSub = subtitles?.find((x) => x.language === subtitle); diff --git a/front/yarn.lock b/front/yarn.lock index 172fa1d3..afbed535 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -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