diff --git a/front/packages/primitives/src/icons.tsx b/front/packages/primitives/src/icons.tsx index 98a1f7f5..bc6f3d1c 100644 --- a/front/packages/primitives/src/icons.tsx +++ b/front/packages/primitives/src/icons.tsx @@ -63,7 +63,8 @@ export const IconButton = ({ const Container = as ?? Pressable; return ( - void; startSeek?: () => void; endSeek?: () => void; + size?: number; } & Stylable) => { const { css } = useYoshiki(); const ref = useRef(null); @@ -49,6 +50,8 @@ export const Slider = ({ const [isFocus, setFocus] = useState(false); const smallBar = !(isSeeking || isHover || isFocus); + const ts = (value: number) => px(value * size); + const change = (event: GestureResponderEvent) => { event.preventDefault(); const locationX = Platform.select({ @@ -58,7 +61,6 @@ export const Slider = ({ setProgress(Math.max(0, Math.min(locationX / layout.width, 1)) * max); }; - // TODO keyboard handling (left, right, up, down) return ( setHover(true)} // @ts-ignore Web only onMouseLeave={() => setHover(false)} - // TODO: This does not work - tabindex={0} + focusable onFocus={() => setFocus(true)} onBlur={() => setFocus(false)} onStartShouldSetResponder={() => true} @@ -84,6 +85,22 @@ export const Slider = ({ onLayout={() => ref.current?.measure((_, __, width, ___, pageX) => setLayout({ width: width, x: pageX })) } + onKeyDown={(e: KeyboardEvent) => { + switch (e.code) { + case "ArrowLeft": + setProgress(Math.max(progress - 0.05 * max, 0)); + break; + case "ArrowRight": + setProgress(Math.min(progress + 0.05 * max, max)); + break; + case "ArrowDown": + setProgress(Math.max(progress - 0.1 * max, 0)); + break; + case "ArrowUp": + setProgress(Math.min(progress + 0.1 * max, max)); + break; + } + }} {...css( { paddingVertical: ts(1), @@ -155,11 +172,12 @@ export const Slider = ({ position: "absolute", top: 0, bottom: 0, - marginY: ts(0.5), + marginY: ts(Platform.OS === "android" ? -0.5 : 0.5), bg: (theme) => theme.accent, width: ts(2), height: ts(2), borderRadius: ts(1), + marginLeft: ts(-1), }, smallBar && { opacity: 0 }, ], diff --git a/front/packages/primitives/src/tooltip.tsx b/front/packages/primitives/src/tooltip.tsx index 7c2fcc79..58720b75 100644 --- a/front/packages/primitives/src/tooltip.tsx +++ b/front/packages/primitives/src/tooltip.tsx @@ -18,13 +18,13 @@ * along with Kyoo. If not, see . */ -import { ToastAndroid, Platform } from "react-native"; +import { ToastAndroid, Platform, ViewProps, PressableProps } from "react-native"; import { Theme } from "yoshiki/native"; -export const tooltip = (tooltip: string) => +export const tooltip = (tooltip: string, up?: boolean) => Platform.select({ web: { - dataSet: { tooltip, label: tooltip }, + dataSet: { tooltip, label: tooltip, tooltipPos: up ? "up" : undefined }, }, android: { onLongPress: () => { @@ -66,6 +66,11 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => { visibility: hidden; transition: opacity 0.3s ease-in-out; } + [data-tooltip-pos]::after { + top: unset; + bottom: 100%; + margin-bottom: 8px; + } :where(body:not(.noHover)) [data-tooltip]:hover::after, [data-tooltip]:focus-visible::after { @@ -81,7 +86,6 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => { outline: none; transition: box-shadow 0.15s ease-in-out; box-shadow: 0 0 0 2px ${theme.colors.black}; - /* box-shadow: ${theme.accent} 1px; */ } `} ); diff --git a/front/packages/ui/src/details/staff.tsx b/front/packages/ui/src/details/staff.tsx index b55a3e57..7685a7c2 100644 --- a/front/packages/ui/src/details/staff.tsx +++ b/front/packages/ui/src/details/staff.tsx @@ -26,16 +26,14 @@ import { PersonAvatar } from "./person"; export const Staff = ({ slug }: { slug: string }) => { const { t } = useTranslation(); - // TODO: handle infinite scroll - return ( - {/* */} {(item, key) => ( . */ -import { IconButton, Link, P, tooltip, ts } from "@kyoo/primitives"; +import { IconButton, Link, P, Slider, tooltip, ts } from "@kyoo/primitives"; import { useAtom, useAtomValue } from "jotai"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; @@ -31,7 +31,7 @@ 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 { useYoshiki } from "yoshiki/native"; +import { px, useYoshiki } from "yoshiki/native"; export const LeftButtons = ({ previousSlug, @@ -53,14 +53,14 @@ export const LeftButtons = ({ icon={SkipPrevious} as={Link} href={previousSlug} - {...tooltip(t("player.previous"))} + {...tooltip(t("player.previous"), true)} {...spacing} /> )} setPlay(!isPlaying)} - {...tooltip(isPlaying ? t("player.pause") : t("player.play"))} + {...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} {...spacing} /> {nextSlug && ( @@ -68,7 +68,7 @@ export const LeftButtons = ({ icon={SkipNext} as={Link} href={nextSlug} - {...tooltip(t("player.next"))} + {...tooltip(t("player.next"), true)} {...spacing} /> )} @@ -84,13 +84,13 @@ const VolumeSlider = () => { const { css } = useYoshiki(); const { t } = useTranslation(); - return null; return ( { ? VolumeDown : VolumeUp } - onClick={() => setMuted(!isMuted)} - {...tooltip(t("mute"))} + onPress={() => setMuted(!isMuted)} + {...tooltip(t("player.mute"), true)} + /> + - - setVolume(value as number)} - size="small" - aria-label={t("volume")} - sx={{ alignSelf: "center" }} - /> - ); }; diff --git a/front/packages/ui/src/player/components/right-buttons.tsx b/front/packages/ui/src/player/components/right-buttons.tsx index 7b5adf6d..086b768c 100644 --- a/front/packages/ui/src/player/components/right-buttons.tsx +++ b/front/packages/ui/src/player/components/right-buttons.tsx @@ -23,7 +23,7 @@ import { IconButton, tooltip } from "@kyoo/primitives"; import { useAtom } from "jotai"; import { useRouter } from "solito/router"; import { useState } from "react"; -import { View } from "react-native"; +import { Platform, 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"; @@ -72,12 +72,13 @@ export const RightButtons = ({ {/* */} {/* */} {/* )} */} - setFullscreen(!isFullscreen)} - {...tooltip(t("player.fullscreen"))} - sx={{ color: "white" }} - /> + {Platform.OS === "web" && ( + setFullscreen(!isFullscreen)} + {...tooltip(t("player.fullscreen"), true)} + /> + )} {/* {subtitleAnchor && ( */} {/* => ({ path: ["watch", slug], parser: WatchItemP, @@ -62,6 +58,14 @@ const mapData = ( }; }; +// 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; +// Number of time the video has been pressed. Used to handle double click. Since there is only one player, +// this can be global and not in the state. +let touchCount = 0; +let touchTimeout: NodeJS.Timeout; + export const Player: QueryPage<{ slug: string }> = ({ slug }) => { const { css } = useYoshiki(); const { t } = useTranslation(); @@ -80,7 +84,7 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => { // useVideoKeyboard(data?.subtitles, data?.fonts, previous, next); const router = useRouter(); - const setFullscreen = useSetAtom(fullscreenAtom); + const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom); const [isPlaying, setPlay] = useAtom(playAtom); const [showHover, setHover] = useState(false); const [mouseMoved, setMouseMoved] = useState(false); @@ -154,7 +158,23 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => { {/* `} */} setMouseMoved(false)} - onPress={Platform.OS === "web" ? () => setPlay(!isPlaying) : show} + onPress={ + Platform.OS === "web" + ? (e) => { + e.preventDefault(); + touchCount++; + if (touchCount == 2) { + touchCount = 0; + setFullscreen(!isFullscreen); + clearTimeout(touchTimeout); + } else + touchTimeout = setTimeout(() => { + touchCount = 0; + }, 400); + setPlay(!isPlaying); + } + : show + } {...css({ flexGrow: 1, // @ts-ignore diff --git a/front/packages/ui/src/player/state.tsx b/front/packages/ui/src/player/state.tsx index 4756fefe..32bedcf6 100644 --- a/front/packages/ui/src/player/state.tsx +++ b/front/packages/ui/src/player/state.tsx @@ -36,6 +36,7 @@ const playModeAtom = atom(PlayMode.Direct); export const playAtom = atom(true); export const loadAtom = atom(false); + export const bufferedAtom = atom(0); export const durationAtom = atom(undefined); @@ -49,18 +50,9 @@ export const progressAtom = atom( const privateProgressAtom = atom(0); const publicProgressAtom = atom(0); -export const [_volumeAtom, volumeAtom] = bakedAtom(100, (get, set, value, baker) => { - const player = get(playerAtom); - if (!player?.current) return; - set(baker, value); - if (player.current) player.current.volume = Math.max(0, Math.min(value, 100)) / 100; -}); -export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker) => { - const player = get(playerAtom); - if (!player?.current) return; - set(baker, value); - if (player.current) player.current.muted = value; -}); +export const volumeAtom = atom(100); +export const mutedAtom = atom(false); + export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker) => { try { if (value) { @@ -82,11 +74,6 @@ export const Video = ({ setError, ...props }: { links?: WatchItem["link"]; setError: (error: string | undefined) => void } & VideoProps) => { - // const player = useRef(null); - // const setPlayer = useSetAtom(playerAtom); - // const setVolume = useSetAtom(_volumeAtom); - // const setMuted = useSetAtom(_mutedAtom); - // const setFullscreen = useSetAtom(fullscreenAtom); // const [playMode, setPlayMode] = useAtom(playModeAtom); const ref = useRef(null); @@ -101,17 +88,12 @@ export const Video = ({ const setPrivateProgress = useSetAtom(privateProgressAtom); const setBuffered = useSetAtom(bufferedAtom); const setDuration = useSetAtom(durationAtom); - useEffect(() => { ref.current?.setStatusAsync({ positionMillis: publicProgress }); }, [publicProgress]); - // setPlayer(player); - - // useEffect(() => { - // if (!player.current) return; - // setPlay(!player.current.paused); - // }, [setPlay]); + const volume = useAtomValue(volumeAtom); + const isMuted = useAtomValue(mutedAtom); // useEffect(() => { // setPlayMode(PlayMode.Direct); @@ -150,6 +132,8 @@ export const Video = ({ {...props} source={links ? { uri: links.direct } : undefined} shouldPlay={isPlaying} + isMuted={isMuted} + volume={volume} onPlaybackStatusUpdate={(status) => { if (!status.isLoaded) { setLoad(true); @@ -158,7 +142,6 @@ export const Video = ({ } setLoad(status.isPlaying !== status.shouldPlay); - setPlay(status.shouldPlay); setPrivateProgress(status.positionMillis); setBuffered(status.playableDurationMillis ?? 0); setDuration(status.durationMillis);