From fc9695a2dcedf420b9322d12b085220fdebdc123 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 25 Jul 2025 22:16:31 +0200 Subject: [PATCH] Rewrite player's controls compenents --- front/app.config.ts | 6 +- front/src/ui/player/components/hover.tsx | 482 ------------------ .../src/ui/player/components/left-buttons.tsx | 221 -------- front/src/ui/player/controls/back.tsx | 88 ++++ front/src/ui/player/controls/index.tsx | 307 +++++++++++ front/src/ui/player/controls/misc.tsx | 118 +++++ front/src/ui/player/controls/progress.tsx | 111 ++++ front/src/ui/player/index.tsx | 22 +- front/src/ui/player/{ => old}/keyboard.tsx | 2 +- front/src/ui/player/old/left-buttons.tsx | 141 +++++ .../src/ui/player/{ => old}/media-session.tsx | 4 +- .../{components => old}/right-buttons.tsx | 4 +- .../player/{components => old}/scrubber.tsx | 6 +- front/src/ui/player/{ => old}/state.tsx | 2 +- front/src/ui/player/{ => old}/video.tsx | 4 +- front/src/ui/player/{ => old}/video.web.tsx | 6 +- .../{ => old}/watch-status-observer.tsx | 2 +- 17 files changed, 792 insertions(+), 734 deletions(-) delete mode 100644 front/src/ui/player/components/hover.tsx delete mode 100644 front/src/ui/player/components/left-buttons.tsx create mode 100644 front/src/ui/player/controls/back.tsx create mode 100644 front/src/ui/player/controls/index.tsx create mode 100644 front/src/ui/player/controls/misc.tsx create mode 100644 front/src/ui/player/controls/progress.tsx rename front/src/ui/player/{ => old}/keyboard.tsx (99%) create mode 100644 front/src/ui/player/old/left-buttons.tsx rename front/src/ui/player/{ => old}/media-session.tsx (96%) rename front/src/ui/player/{components => old}/right-buttons.tsx (96%) rename front/src/ui/player/{components => old}/scrubber.tsx (97%) rename front/src/ui/player/{ => old}/state.tsx (99%) rename front/src/ui/player/{ => old}/video.tsx (98%) rename front/src/ui/player/{ => old}/video.web.tsx (98%) rename front/src/ui/player/{ => old}/watch-status-observer.tsx (98%) diff --git a/front/app.config.ts b/front/app.config.ts index 894babbf..5625f80f 100644 --- a/front/app.config.ts +++ b/front/app.config.ts @@ -66,8 +66,12 @@ export const expo: ExpoConfig = { [ "react-native-video", { - enableNotificationControls: true, enableAndroidPictureInPicture: true, + enableBackgroundAudio: true, + androidExtensions: { + useExoplayerDash: true, + useExoplayerHls: true, + }, }, ], ], diff --git a/front/src/ui/player/components/hover.tsx b/front/src/ui/player/components/hover.tsx deleted file mode 100644 index 29ab5799..00000000 --- a/front/src/ui/player/components/hover.tsx +++ /dev/null @@ -1,482 +0,0 @@ -import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; -import { - type ImageStyle, - Platform, - Pressable, - View, - type ViewProps, -} from "react-native"; -import { useEvent, type VideoPlayer } from "react-native-video"; -import { percent, rem, useYoshiki } from "yoshiki/native"; -import type { AudioTrack, Chapter, KImage, Subtitle } from "~/models"; -import { - alpha, - CircularProgress, - ContrastArea, - H1, - H2, - IconButton, - Poster, - PressableFeedback, - Skeleton, - Slider, - Tooltip, - tooltip, - ts, - useIsTouch, -} from "~/primitives"; -import { LeftButtons, TouchControls } from "./left-buttons"; -import { RightButtons } from "./right-buttons"; -import { BottomScrubber, ScrubberTooltip } from "./scrubber"; - -export const Hover = ({ - isLoading, - url, - name, - showName, - poster, - chapters, - subtitles, - audios, - fonts, - previousSlug, - nextSlug, -}: { - isLoading: boolean; - url: string; - name?: string | null; - showName?: string; - poster?: KyooImage | null; - chapters?: Chapter[]; - subtitles?: Subtitle[]; - audios?: Audio[]; - fonts?: string[]; - previousSlug?: string | null; - nextSlug?: string | null; -}) => { - const show = useAtomValue(hoverAtom); - const setHover = useSetAtom(hoverReasonAtom); - const isSeeking = useAtomValue(seekingAtom); - const isTouch = useIsTouch(); - - const showBottomSeeker = isSeeking && isTouch; - - return ( - - {({ css }) => ( - <> - - { - if (e.nativeEvent.pointerType === "mouse") - setHover((x) => ({ ...x, mouseHover: true })); - }} - onPointerLeave={(e) => { - if (e.nativeEvent.pointerType === "mouse") - setHover((x) => ({ ...x, mouseHover: false })); - }} - {...css({ - // TODO: animate show - display: !show ? "none" : "flex", - position: "absolute", - top: 0, - left: 0, - bottom: 0, - right: 0, - // box-none does not work on the web while none does not work on android - pointerEvents: Platform.OS === "web" ? "none" : "box-none", - })} - > - - theme.darkOverlay, - flexDirection: "row", - pointerEvents: "auto", - padding: percent(1), - })} - > - - - {!showBottomSeeker && ( -

- {isLoading ? ( - - ) : ( - name - )} -

- )} - - {showBottomSeeker ? ( - - ) : ( - - - - setHover((x) => ({ ...x, menuOpened: true })) - } - onMenuClose={() => { - // Disable hover since the menu overlay makes the mouseout unreliable. - setHover((x) => ({ - ...x, - menuOpened: false, - mouseHover: false, - })); - }} - /> - - )} -
-
-
- - )} -
- ); -}; - -export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => { - const hover = useAtomValue(hoverAtom); - const setHover = useSetAtom(hoverReasonAtom); - const mouseCallback = useRef(null); - const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ - count: 0, - }); - const playerWidth = useRef(null); - const isTouch = useIsTouch(); - - const show = useCallback(() => { - setHover((x) => ({ ...x, mouseMoved: true })); - if (mouseCallback.current) clearTimeout(mouseCallback.current); - mouseCallback.current = setTimeout(() => { - setHover((x) => ({ ...x, mouseMoved: false })); - }, 2500); - }, [setHover]); - - // On mouse move - useEffect(() => { - if (Platform.OS !== "web") return; - const handler = (e: PointerEvent) => { - if (e.pointerType !== "mouse") return; - show(); - }; - - document.addEventListener("pointermove", handler); - return () => document.removeEventListener("pointermove", handler); - }, [show]); - - // When the controls hide, remove focus so space can be used to play/pause instead of triggering the button - // It also serves to hide the tooltip. - useEffect(() => { - if (Platform.OS !== "web") return; - if (!hover && document.activeElement instanceof HTMLElement) - document.activeElement.blur(); - }, [hover]); - - const { css } = useYoshiki(); - - const duration = useAtomValue(durationAtom); - const setPlay = useSetAtom(playAtom); - const setProgress = useSetAtom(progressAtom); - const setFullscreen = useSetAtom(fullscreenAtom); - - const onPress = (e: { pointerType: string; x: number }) => { - if (Platform.OS === "web" && e.pointerType === "mouse") { - setPlay((x) => !x); - return; - } - if (hover) setHover((x) => ({ ...x, mouseMoved: false })); - else show(); - }; - const onDoublePress = (e: { pointerType: string; x: number }) => { - if (Platform.OS === "web" && e.pointerType === "mouse") { - // Only reset touch count for the web, on mobile you can continue to seek by pressing again. - touch.current.count = 0; - setFullscreen((x) => !x); - return; - } - - show(); - if (!duration || !playerWidth.current) return; - - if (e.x < playerWidth.current * 0.33) { - setProgress((x) => Math.max(x - 10, 0)); - } - if (e.x > playerWidth.current * 0.66) { - setProgress((x) => Math.min(x + 10, duration)); - } - }; - - const onAnyPress = (e: { pointerType: string; x: number }) => { - touch.current.count++; - if (touch.current.count >= 2) { - onDoublePress(e); - clearTimeout(touch.current.timeout); - } else { - onPress(e); - } - - touch.current.timeout = setTimeout(() => { - touch.current.count = 0; - touch.current.timeout = undefined; - }, 400); - }; - - return ( - { - if (e.nativeEvent.pointerType === "mouse") - setHover((x) => ({ ...x, mouseMoved: false })); - }} - onPress={(e) => { - e.preventDefault(); - onAnyPress({ - pointerType: isTouch ? "touch" : "mouse", - x: e.nativeEvent.locationX ?? e.nativeEvent.pageX, - }); - }} - onLayout={(e) => { - playerWidth.current = e.nativeEvent.layout.width; - }} - {...css( - // @ts-expect-error Web only property (cursor: unset) - { - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - cursor: hover ? "unset" : "none", - }, - props, - )} - > - {children} - - ); -}; - -const ProgressBar = ({ - url, - chapters, -}: { - url: string; - chapters?: Chapter[]; -}) => { - const [progress, setProgress] = useAtom(progressAtom); - const buffered = useAtomValue(bufferedAtom); - const duration = useAtomValue(durationAtom); - const setPlay = useSetAtom(playAtom); - const [hoverProgress, setHoverProgress] = useState(null); - const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 }); - const [seekProgress, setSeekProgress] = useAtom(seekProgressAtom); - const setSeeking = useSetAtom(seekingAtom); - - return ( - <> - { - setPlay(false); - setSeeking(true); - }} - endSeek={() => { - setSeeking(false); - setProgress(seekProgress!); - setSeekProgress(null); - setTimeout(() => setPlay(true), 10); - }} - onHover={(progress, layout) => { - setHoverProgress(progress); - setLayout(layout); - }} - setProgress={(progress) => setSeekProgress(progress)} - subtleProgress={buffered} - max={duration} - markers={chapters?.map((x) => x.startTime)} - dataSet={{ tooltipId: "progress-scrubber" }} - /> - - hoverProgress ? ( - - ) : null - } - opacity={1} - style={{ padding: 0, borderRadius: imageBorderRadius }} - /> - - ); -}; - -export const Back = ({ - isLoading, - name, - ...props -}: { isLoading: boolean; name?: string } & ViewProps) => { - const { css } = useYoshiki(); - const { t } = useTranslation(); - const router = useRouter(); - - return ( - theme.darkOverlay, - display: "flex", - flexDirection: "row", - alignItems: "center", - padding: percent(0.33), - color: "white", - }, - props, - )} - > - - - {isLoading ? ( - - ) : ( -

- {name} -

- )} -
-
- ); -}; - -const VideoPoster = ({ - poster, - alt, - isLoading, -}: { - poster?: KyooImage | null; - alt?: string; - isLoading: boolean; -}) => { - const { css } = useYoshiki(); - - return ( - - - - ); -}; - -export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => { - const { css } = useYoshiki(); - const [isLoading, setLoading] = useState(false); - - useEvent(player, "onStatusChange", (status) => { - setLoading(status === "loading"); - }); - - if (!isLoading) return null; - - return ( - alpha(theme.colors.black, 0.3), - justifyContent: "center", - })} - > - - - ); -}; diff --git a/front/src/ui/player/components/left-buttons.tsx b/front/src/ui/player/components/left-buttons.tsx deleted file mode 100644 index e92c733b..00000000 --- a/front/src/ui/player/components/left-buttons.tsx +++ /dev/null @@ -1,221 +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 { IconButton, Link, P, Slider, noTouch, tooltip, touchOnly, ts } from "@kyoo/primitives"; -import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg"; -import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; -import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg"; -import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg"; -import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg"; -import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg"; -import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg"; -import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg"; -import { useAtom, useAtomValue } from "jotai"; -import { useTranslation } from "react-i18next"; -import { Platform, View } from "react-native"; -import { type Stylable, px, useYoshiki } from "yoshiki/native"; -import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; -import { HoverTouch, hoverAtom } from "./hover"; - -export const LeftButtons = ({ - previousSlug, - nextSlug, -}: { - previousSlug?: string | null; - nextSlug?: string | null; -}) => { - const { css } = useYoshiki(); - const { t } = useTranslation(); - const [isPlaying, setPlay] = useAtom(playAtom); - - const spacing = css({ marginHorizontal: ts(1) }); - - return ( - - - {previousSlug && ( - - )} - setPlay(!isPlaying)} - {...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} - {...spacing} - /> - {nextSlug && ( - - )} - {Platform.OS === "web" && } - - - - ); -}; - -export const TouchControls = ({ - previousSlug, - nextSlug, - ...props -}: { - previousSlug?: string | null; - nextSlug?: string | null; -}) => { - const { css } = useYoshiki(); - const [isPlaying, setPlay] = useAtom(playAtom); - const hover = useAtomValue(hoverAtom); - - const common = css( - [ - { - backgroundColor: (theme) => theme.darkOverlay, - marginHorizontal: ts(3), - }, - ], - touchOnly, - ); - - return ( - - {hover && ( - <> - - setPlay(!isPlaying)} - size={ts(8)} - {...common} - /> - - - )} - - ); -}; - -const VolumeSlider = () => { - const [volume, setVolume] = useAtom(volumeAtom); - const [isMuted, setMuted] = useAtom(mutedAtom); - const { css } = useYoshiki(); - const { t } = useTranslation(); - - return ( - - setMuted(!isMuted)} - {...tooltip(t("player.mute"), true)} - /> - - - ); -}; - -const ProgressText = (props: Stylable) => { - const progress = useAtomValue(progressAtom); - const duration = useAtomValue(durationAtom); - const { css } = useYoshiki(); - - return ( -

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

- ); -}; - -export const toTimerString = (timer?: number, duration?: number) => { - if (!duration) duration = timer; - if ( - timer === undefined || - duration === undefined || - Number.isNaN(duration) || - Number.isNaN(timer) - ) - return "??:??"; - const h = Math.floor(timer / 3600); - const min = Math.floor((timer / 60) % 60); - const sec = Math.floor(timer % 60); - const fmt = (n: number) => n.toString().padStart(2, "0"); - - if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`; - return `${fmt(min)}:${fmt(sec)}`; -}; diff --git a/front/src/ui/player/controls/back.tsx b/front/src/ui/player/controls/back.tsx new file mode 100644 index 00000000..72e9fc3c --- /dev/null +++ b/front/src/ui/player/controls/back.tsx @@ -0,0 +1,88 @@ +import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; +import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { View, type ViewProps } from "react-native"; +import { percent, rem, useYoshiki } from "yoshiki/native"; +import { + H1, + IconButton, + PressableFeedback, + Skeleton, + tooltip, +} from "~/primitives"; + +export const Back = ({ name, ...props }: { name: string } & ViewProps) => { + const { css } = useYoshiki(); + const { t } = useTranslation(); + const router = useRouter(); + + return ( + theme.darkOverlay, + display: "flex", + flexDirection: "row", + alignItems: "center", + padding: percent(0.33), + color: "white", + }, + props, + )} + > + +

+ {name} +

+
+ ); +}; + +Back.Loader = (props: ViewProps) => { + const { css } = useYoshiki(); + const { t } = useTranslation(); + const router = useRouter(); + + return ( + theme.darkOverlay, + display: "flex", + flexDirection: "row", + alignItems: "center", + padding: percent(0.33), + color: "white", + }, + props, + )} + > + + + + ); +}; diff --git a/front/src/ui/player/controls/index.tsx b/front/src/ui/player/controls/index.tsx new file mode 100644 index 00000000..744ad206 --- /dev/null +++ b/front/src/ui/player/controls/index.tsx @@ -0,0 +1,307 @@ +import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; +import { useRouter } from "expo-router"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + type ImageStyle, + Platform, + Pressable, + View, + type ViewProps, +} from "react-native"; +import { useEvent, type VideoPlayer } from "react-native-video"; +import { percent, rem, useYoshiki } from "yoshiki/native"; +import type { AudioTrack, Chapter, KImage, Subtitle } from "~/models"; +import { + alpha, + CircularProgress, + H1, + H2, + IconButton, + Poster, + PressableFeedback, + Skeleton, + Slider, + Tooltip, + tooltip, + ts, + useIsTouch, +} from "~/primitives"; +import { LeftButtons } from "./components/left-buttons"; +import { RightButtons } from "./components/right-buttons"; +import { BottomScrubber, ScrubberTooltip } from "./scrubber"; + +export const Controls = ({ + player, + title, +}: { + player: VideoPlayer; + title: string; + description: string | null; + poster: KImage | null; +}) => { + const { css } = useYoshiki(); + // const show = useAtomValue(hoverAtom); + // const setHover = useSetAtom(hoverReasonAtom); + // const isSeeking = useAtomValue(seekingAtom); + // const isTouch = useIsTouch(); + + // const showBottomSeeker = isSeeking && isTouch; + + // + return ( + { + // if (e.nativeEvent.pointerType === "mouse") + // setHover((x) => ({ ...x, mouseHover: true })); + // }} + // onPointerLeave={(e) => { + // if (e.nativeEvent.pointerType === "mouse") + // setHover((x) => ({ ...x, mouseHover: false })); + // }} + {...css({ + // TODO: animate show + //display: !show ? "none" : "flex", + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + // box-none does not work on the web while none does not work on android + pointerEvents: Platform.OS === "web" ? "none" : "box-none", + })} + > + + theme.darkOverlay, + flexDirection: "row", + pointerEvents: "auto", + padding: percent(1), + })} + > + + + {!showBottomSeeker && ( +

+ {isLoading ? ( + + ) : ( + name + )} +

+ )} + + {showBottomSeeker ? ( + + ) : ( + + + setHover((x) => ({ ...x, menuOpened: true }))} + onMenuClose={() => { + // Disable hover since the menu overlay makes the mouseout unreliable. + setHover((x) => ({ + ...x, + menuOpened: false, + mouseHover: false, + })); + }} + /> + + )} +
+
+
+ ); +}; + +export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => { + const hover = useAtomValue(hoverAtom); + const setHover = useSetAtom(hoverReasonAtom); + const mouseCallback = useRef(null); + const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ + count: 0, + }); + const playerWidth = useRef(null); + const isTouch = useIsTouch(); + + const show = useCallback(() => { + setHover((x) => ({ ...x, mouseMoved: true })); + if (mouseCallback.current) clearTimeout(mouseCallback.current); + mouseCallback.current = setTimeout(() => { + setHover((x) => ({ ...x, mouseMoved: false })); + }, 2500); + }, [setHover]); + + // On mouse move + useEffect(() => { + if (Platform.OS !== "web") return; + const handler = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + show(); + }; + + document.addEventListener("pointermove", handler); + return () => document.removeEventListener("pointermove", handler); + }, [show]); + + // When the controls hide, remove focus so space can be used to play/pause instead of triggering the button + // It also serves to hide the tooltip. + useEffect(() => { + if (Platform.OS !== "web") return; + if (!hover && document.activeElement instanceof HTMLElement) + document.activeElement.blur(); + }, [hover]); + + const { css } = useYoshiki(); + + const duration = useAtomValue(durationAtom); + const setPlay = useSetAtom(playAtom); + const setProgress = useSetAtom(progressAtom); + const setFullscreen = useSetAtom(fullscreenAtom); + + const onPress = (e: { pointerType: string; x: number }) => { + if (Platform.OS === "web" && e.pointerType === "mouse") { + setPlay((x) => !x); + return; + } + if (hover) setHover((x) => ({ ...x, mouseMoved: false })); + else show(); + }; + const onDoublePress = (e: { pointerType: string; x: number }) => { + if (Platform.OS === "web" && e.pointerType === "mouse") { + // Only reset touch count for the web, on mobile you can continue to seek by pressing again. + touch.current.count = 0; + setFullscreen((x) => !x); + return; + } + + show(); + if (!duration || !playerWidth.current) return; + + if (e.x < playerWidth.current * 0.33) { + setProgress((x) => Math.max(x - 10, 0)); + } + if (e.x > playerWidth.current * 0.66) { + setProgress((x) => Math.min(x + 10, duration)); + } + }; + + const onAnyPress = (e: { pointerType: string; x: number }) => { + touch.current.count++; + if (touch.current.count >= 2) { + onDoublePress(e); + clearTimeout(touch.current.timeout); + } else { + onPress(e); + } + + touch.current.timeout = setTimeout(() => { + touch.current.count = 0; + touch.current.timeout = undefined; + }, 400); + }; + + return ( + { + if (e.nativeEvent.pointerType === "mouse") + setHover((x) => ({ ...x, mouseMoved: false })); + }} + onPress={(e) => { + e.preventDefault(); + onAnyPress({ + pointerType: isTouch ? "touch" : "mouse", + x: e.nativeEvent.locationX ?? e.nativeEvent.pageX, + }); + }} + onLayout={(e) => { + playerWidth.current = e.nativeEvent.layout.width; + }} + {...css( + // @ts-expect-error Web only property (cursor: unset) + { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + cursor: hover ? "unset" : "none", + }, + props, + )} + > + {children} + + ); +}; + +const VideoPoster = ({ + poster, + alt, + isLoading, +}: { + poster?: KyooImage | null; + alt?: string; + isLoading: boolean; +}) => { + const { css } = useYoshiki(); + + return ( + + + + ); +}; diff --git a/front/src/ui/player/controls/misc.tsx b/front/src/ui/player/controls/misc.tsx new file mode 100644 index 00000000..3a6744f0 --- /dev/null +++ b/front/src/ui/player/controls/misc.tsx @@ -0,0 +1,118 @@ +import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg"; +import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; +import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg"; +import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg"; +import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg"; +import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { useEvent, type VideoPlayer } from "react-native-video"; +import { px, useYoshiki } from "yoshiki/native"; +import { + alpha, + CircularProgress, + IconButton, + Slider, + tooltip, + ts, +} from "~/primitives"; + +export const PlayButton = ({ player, ...props }: { player: VideoPlayer }) => { + const { t } = useTranslation(); + + const [playing, setPlay] = useState(player.isPlaying); + useEvent(player, "onPlaybackStateChange", (status) => { + setPlay(status.isPlaying); + }); + + return ( + { + if (playing) player.pause(); + else player.play(); + }} + {...tooltip(playing ? t("player.pause") : t("player.play"), true)} + {...props} + /> + ); +}; + +export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => { + const { css } = useYoshiki(); + const { t } = useTranslation(); + + const [volume, setVolume] = useState(player.volume); + useEvent(player, "onVolumeChange", setVolume); + // TODO: listen to `player.muted` changes (currently hook does not exists + // const [muted, setMuted] = useState(player.muted); + const muted = player.muted; + + return ( + + { + player.muted = !muted; + }} + {...tooltip(t("player.mute"), true)} + /> + { + player.volume = vol; + }} + size={4} + {...css({ width: px(100) })} + {...tooltip(t("player.volume"), true)} + /> + + ); +}; + +export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => { + const { css } = useYoshiki(); + const [isLoading, setLoading] = useState(false); + + useEvent(player, "onStatusChange", (status) => { + setLoading(status === "loading"); + }); + + if (!isLoading) return null; + + return ( + alpha(theme.colors.black, 0.3), + justifyContent: "center", + })} + > + + + ); +}; diff --git a/front/src/ui/player/controls/progress.tsx b/front/src/ui/player/controls/progress.tsx new file mode 100644 index 00000000..24a5030d --- /dev/null +++ b/front/src/ui/player/controls/progress.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import type { TextProps } from "react-native"; +import { useEvent, type VideoPlayer } from "react-native-video"; +import { useYoshiki } from "yoshiki/native"; +import type { Chapter } from "~/models"; +import { P, Slider } from "~/primitives"; + +export const ProgressBar = ({ + player, + // url, + chapters, +}: { + player: VideoPlayer; + // url: string; + chapters?: Chapter[]; +}) => { + const [duration, setDuration] = useState(player.duration || 100); + useEvent(player, "onLoad", (info) => { + if (info.duration) setDuration(info.duration); + }); + + const [progress, setProgress] = useState(player.currentTime || 0); + const [buffer, setBuffer] = useState(0); + useEvent(player, "onProgress", (progress) => { + setProgress(progress.currentTime); + setBuffer(progress.bufferDuration); + }); + + const [seek, setSeek] = useState(null); + + return ( + <> + { + player.pause(); + }} + setProgress={setSeek} + endSeek={() => { + setProgress(seek!); + setSeek(null); + setTimeout(player.play, 10); + }} + // onHover={(progress, layout) => { + // setHoverProgress(progress); + // setLayout(layout); + // }} + markers={chapters?.map((x) => x.startTime)} + // dataSet={{ tooltipId: "progress-scrubber" }} + /> + {/* */} + {/* hoverProgress ? ( */} + {/* */} + {/* ) : null */} + {/* } */} + {/* opacity={1} */} + {/* style={{ padding: 0, borderRadius: imageBorderRadius }} */} + {/* /> */} + + ); +}; + +export const ProgressText = ({ + player, + ...props +}: { player: VideoPlayer } & TextProps) => { + const { css } = useYoshiki(); + + const [progress, setProgress] = useState(player.currentTime || 0); + useEvent(player, "onProgress", (progress) => { + setProgress(progress.currentTime); + }); + const [duration, setDuration] = useState(player.duration || 100); + useEvent(player, "onLoad", (info) => { + if (info.duration) setDuration(info.duration); + }); + + return ( +

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

+ ); +}; + +const toTimerString = (timer?: number, duration?: number) => { + if (!duration) duration = timer; + if (timer === undefined || Number.isNaN(timer)) return "??:??"; + + const h = Math.floor(timer / 3600); + const min = Math.floor((timer / 60) % 60); + const sec = Math.floor(timer % 60); + const fmt = (n: number) => n.toString().padStart(2, "0"); + + return h !== 0 || (duration && duration >= 3600) + ? `${fmt(h)}:${fmt(min)}:${fmt(sec)}` + : `${fmt(min)}:${fmt(sec)}`; +}; diff --git a/front/src/ui/player/index.tsx b/front/src/ui/player/index.tsx index cb947041..bef34123 100644 --- a/front/src/ui/player/index.tsx +++ b/front/src/ui/player/index.tsx @@ -1,24 +1,14 @@ import { Stack, useRouter } from "expo-router"; -import { useEffect, useRef } from "react"; import { StyleSheet, View } from "react-native"; -import { - useEvent, - useVideoPlayer, - VideoView, - VideoViewRef, -} from "react-native-video"; +import { useEvent, useVideoPlayer, VideoView } from "react-native-video"; import { entryDisplayNumber } from "~/components/entries"; import { FullVideo, VideoInfo } from "~/models"; -import { Head } from "~/primitives"; +import { ContrastArea, Head } from "~/primitives"; import { useToken } from "~/providers/account-context"; import { useLocalSetting } from "~/providers/settings"; import { type QueryIdentifier, useFetch } from "~/query"; import { useQueryState } from "~/utils"; -import { LoadingIndicator } from "./components/hover"; - -// import { Hover, LoadingIndicator } from "./components/hover"; -// import { useVideoKeyboard } from "./keyboard"; -// import { durationAtom, fullscreenAtom, Video } from "./state"; +import { LoadingIndicator } from "./controls"; const mapMetadata = (item: FullVideo | undefined) => { if (!item) return null; @@ -142,8 +132,10 @@ export const Player = () => { controls style={StyleSheet.absoluteFillObject} /> - - {/* */} + + + + ); }; diff --git a/front/src/ui/player/keyboard.tsx b/front/src/ui/player/old/keyboard.tsx similarity index 99% rename from front/src/ui/player/keyboard.tsx rename to front/src/ui/player/old/keyboard.tsx index 5cd828b9..13ba652e 100644 --- a/front/src/ui/player/keyboard.tsx +++ b/front/src/ui/player/old/keyboard.tsx @@ -31,7 +31,7 @@ import { progressAtom, subtitleAtom, volumeAtom, -} from "./state"; +} from "./old/statee"; type Action = | { type: "play" } diff --git a/front/src/ui/player/old/left-buttons.tsx b/front/src/ui/player/old/left-buttons.tsx new file mode 100644 index 00000000..cd97c70f --- /dev/null +++ b/front/src/ui/player/old/left-buttons.tsx @@ -0,0 +1,141 @@ +import { + IconButton, + Link, + noTouch, + tooltip, + touchOnly, + ts, +} from "@kyoo/primitives"; +import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg"; +import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; +import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg"; +import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg"; +import { useAtom, useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { Platform, View } from "react-native"; +import { px, type Stylable, useYoshiki } from "yoshiki/native"; +import { HoverTouch, hoverAtom } from "../controls"; +import { playAtom } from "./state"; + +export const LeftButtons = ({ + previousSlug, + nextSlug, +}: { + previousSlug?: string | null; + nextSlug?: string | null; +}) => { + const { css } = useYoshiki(); + const { t } = useTranslation(); + const [isPlaying, setPlay] = useAtom(playAtom); + + const spacing = css({ marginHorizontal: ts(1) }); + + return ( + + + {previousSlug && ( + + )} + setPlay(!isPlaying)} + {...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} + {...spacing} + /> + {nextSlug && ( + + )} + {Platform.OS === "web" && } + + + + ); +}; + +export const TouchControls = ({ + previousSlug, + nextSlug, + ...props +}: { + previousSlug?: string | null; + nextSlug?: string | null; +}) => { + const { css } = useYoshiki(); + const [isPlaying, setPlay] = useAtom(playAtom); + const hover = useAtomValue(hoverAtom); + + const common = css( + [ + { + backgroundColor: (theme) => theme.darkOverlay, + marginHorizontal: ts(3), + }, + ], + touchOnly, + ); + + return ( + + {hover && ( + <> + + setPlay(!isPlaying)} + size={ts(8)} + {...common} + /> + + + )} + + ); +}; diff --git a/front/src/ui/player/media-session.tsx b/front/src/ui/player/old/media-session.tsx similarity index 96% rename from front/src/ui/player/media-session.tsx rename to front/src/ui/player/old/media-session.tsx index f5d2d77a..64b74dfd 100644 --- a/front/src/ui/player/media-session.tsx +++ b/front/src/ui/player/old/media-session.tsx @@ -21,8 +21,8 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useEffect } from "react"; import { useRouter } from "solito/router"; -import { reducerAtom } from "./keyboard"; -import { durationAtom, playAtom, progressAtom } from "./state"; +import { reducerAtom } from "./old/keyboardd"; +import { durationAtom, playAtom, progressAtom } from "./old/statee"; export const MediaSessionManager = ({ title, diff --git a/front/src/ui/player/components/right-buttons.tsx b/front/src/ui/player/old/right-buttons.tsx similarity index 96% rename from front/src/ui/player/components/right-buttons.tsx rename to front/src/ui/player/old/right-buttons.tsx index 72ac2bd9..3f095ed9 100644 --- a/front/src/ui/player/components/right-buttons.tsx +++ b/front/src/ui/player/old/right-buttons.tsx @@ -30,8 +30,8 @@ import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { type Stylable, useYoshiki } from "yoshiki/native"; import { useSubtitleName } from "../../../../packages/ui/src/utils"; -import { fullscreenAtom, subtitleAtom } from "../state"; -import { AudiosMenu, QualitiesMenu } from "../video"; +import { fullscreenAtom, subtitleAtom } from "./state"; +import { AudiosMenu, QualitiesMenu } from "./video"; export const RightButtons = ({ audios, diff --git a/front/src/ui/player/components/scrubber.tsx b/front/src/ui/player/old/scrubber.tsx similarity index 97% rename from front/src/ui/player/components/scrubber.tsx rename to front/src/ui/player/old/scrubber.tsx index 0487cce1..8cdb374e 100644 --- a/front/src/ui/player/components/scrubber.tsx +++ b/front/src/ui/player/old/scrubber.tsx @@ -25,9 +25,9 @@ import { useMemo } from "react"; import { Platform, View } from "react-native"; import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native"; import { ErrorView } from "../../errors"; -import { durationAtom } from "../state"; -import { seekProgressAtom } from "./hover"; -import { toTimerString } from "./left-buttons"; +import { durationAtom } from "./state"; +import { seekProgressAtom } from "../controls"; +import { toTimerString } from "../controls/left-buttonsttons"; type Thumb = { from: number; diff --git a/front/src/ui/player/state.tsx b/front/src/ui/player/old/state.tsx similarity index 99% rename from front/src/ui/player/state.tsx rename to front/src/ui/player/old/state.tsx index 0bb704b8..24d97cdb 100644 --- a/front/src/ui/player/state.tsx +++ b/front/src/ui/player/old/state.tsx @@ -33,7 +33,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { Platform } from "react-native"; -import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "./video"; +import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "../videoideo"; export const playAtom = atom(true); export const loadAtom = atom(false); diff --git a/front/src/ui/player/video.tsx b/front/src/ui/player/old/video.tsx similarity index 98% rename from front/src/ui/player/video.tsx rename to front/src/ui/player/old/video.tsx index 3ce3e985..c032bf1f 100644 --- a/front/src/ui/player/video.tsx +++ b/front/src/ui/player/old/video.tsx @@ -50,8 +50,8 @@ import NativeVideo, { SelectedVideoTrackType, } from "react-native-video"; import { useYoshiki } from "yoshiki/native"; -import { useDisplayName } from "../../../packages/ui/src/utils"; -import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./state"; +import { useDisplayName } from "../../../../packages/ui/src/utils"; +import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./old/statee"; const MimeTypes: Map = new Map([ ["subrip", "application/x-subrip"], diff --git a/front/src/ui/player/video.web.tsx b/front/src/ui/player/old/video.web.tsx similarity index 98% rename from front/src/ui/player/video.web.tsx rename to front/src/ui/player/old/video.web.tsx index 60be3f20..b4de146e 100644 --- a/front/src/ui/player/video.web.tsx +++ b/front/src/ui/player/old/video.web.tsx @@ -36,9 +36,9 @@ import { useTranslation } from "react-i18next"; import type { VideoProps } from "react-native-video"; import toVttBlob from "srt-webvtt"; import { useForceRerender, useYoshiki } from "yoshiki"; -import { useDisplayName } from "../../../packages/ui/src/utils"; -import { MediaSessionManager } from "./media-session"; -import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./state"; +import { useDisplayName } from "../../../../packages/ui/src/utils"; +import { MediaSessionManager } from "./old/media-sessionn"; +import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./old/statee"; let hls: Hls | null = null; diff --git a/front/src/ui/player/watch-status-observer.tsx b/front/src/ui/player/old/watch-status-observer.tsx similarity index 98% rename from front/src/ui/player/watch-status-observer.tsx rename to front/src/ui/player/old/watch-status-observer.tsx index decd2f7f..ba06b60f 100644 --- a/front/src/ui/player/watch-status-observer.tsx +++ b/front/src/ui/player/old/watch-status-observer.tsx @@ -23,7 +23,7 @@ import { useMutation } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { useAtomCallback } from "jotai/utils"; import { useCallback, useEffect } from "react"; -import { playAtom, progressAtom } from "./state"; +import { playAtom, progressAtom } from "./old/statee"; export const WatchStatusObserver = ({ type,