diff --git a/front/packages/primitives/src/slider.tsx b/front/packages/primitives/src/slider.tsx index 4de44eae..6c794620 100644 --- a/front/packages/primitives/src/slider.tsx +++ b/front/packages/primitives/src/slider.tsx @@ -19,8 +19,10 @@ */ import { useRef, useState } from "react"; -import { GestureResponderEvent, Platform, View } from "react-native"; +import { GestureResponderEvent, Platform, Pressable, View } from "react-native"; +import { useTVEventHandler } from "@kyoo/primitives/tv"; import { px, percent, Stylable, useYoshiki } from "yoshiki/native"; +import { focusReset } from "./utils"; export const Slider = ({ progress, @@ -49,7 +51,6 @@ export const Slider = ({ const [isHover, setHover] = useState(false); const [isFocus, setFocus] = useState(false); const smallBar = !(isSeeking || isHover || isFocus); - const ts = (value: number) => px(value * size); const change = (event: GestureResponderEvent) => { @@ -61,16 +62,17 @@ export const Slider = ({ setProgress(Math.max(0, Math.min(locationX / layout.width, 1)) * max); }; + useTVEventHandler((e) => { + if (!isFocus) return; + + if (e.eventType === "left" && e.eventKeyAction === 0) setProgress(Math.max(progress - 5, 0)); + if (e.eventType === "right" && e.eventKeyAction === 0) setProgress(Math.max(progress + 5, 0)); + }); + + const Container = Platform.isTV ? Pressable : View; return ( - setHover(true)} - // @ts-ignore Web only - onMouseLeave={() => setHover(false)} - focusable - onFocus={() => setFocus(true)} - onBlur={() => setFocus(false)} onStartShouldSetResponder={() => true} onResponderGrant={() => { setSeek(true); @@ -85,6 +87,7 @@ export const Slider = ({ onLayout={() => ref.current?.measure((_, __, width, ___, pageX) => setLayout({ width: width, x: pageX })) } + // @ts-ignore Web only onKeyDown={(e: KeyboardEvent) => { switch (e.code) { case "ArrowLeft": @@ -107,10 +110,16 @@ export const Slider = ({ // @ts-ignore Web only cursor: "pointer", focus: { - shadowRadius: 0, + self: focusReset, }, }, - props, + { + onFocus: () => setFocus(true), + onBlur: () => setFocus(false), + onMouseEnter: () => setHover(true), + onMouseLeave: () => setHover(false), + ...props, + }, )} > theme.accent, width: ts(2), height: ts(2), @@ -190,6 +199,6 @@ export const Slider = ({ }, )} /> - + ); }; diff --git a/front/packages/primitives/tv.ts b/front/packages/primitives/tv.ts new file mode 100644 index 00000000..5dd1e9e8 --- /dev/null +++ b/front/packages/primitives/tv.ts @@ -0,0 +1,23 @@ +/* + * 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 "./tvos-type.d.ts"; + +export { useTVEventHandler } from "react-native"; diff --git a/front/packages/primitives/tv.web.ts b/front/packages/primitives/tv.web.ts new file mode 100644 index 00000000..834b9f95 --- /dev/null +++ b/front/packages/primitives/tv.web.ts @@ -0,0 +1,21 @@ +/* + * 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 . + */ + +export const useTVEventHandler = () => {}; diff --git a/front/packages/primitives/tvos-type.d.ts b/front/packages/primitives/tvos-type.d.ts new file mode 100644 index 00000000..92dd36a2 --- /dev/null +++ b/front/packages/primitives/tvos-type.d.ts @@ -0,0 +1,178 @@ +/* + * 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 React from "react"; +import { ViewProps, ScrollViewProps } from "react-native"; + +declare module "react-native" { + interface ViewProps { + /** + * TV next focus down (see documentation for the View component). + */ + nextFocusDown?: number; + + /** + * TV next focus forward (see documentation for the View component). + * + * @platform android + */ + nextFocusForward?: number; + + /** + * TV next focus left (see documentation for the View component). + */ + nextFocusLeft?: number; + + /** + * TV next focus right (see documentation for the View component). + */ + nextFocusRight?: number; + + /** + * TV next focus up (see documentation for the View component). + */ + nextFocusUp?: number; + } + + export const useTVEventHandler: (handleEvent: (event: HWEvent) => void) => void; + + export const TVEventControl: { + enableTVMenuKey(): void; + disableTVMenuKey(): void; + enableTVPanGesture(): void; + disableTVPanGesture(): void; + enableGestureHandlersCancelTouches(): void; + disableGestureHandlersCancelTouches(): void; + }; + + export type HWEvent = { + eventType: + | "up" + | "down" + | "right" + | "left" + | "longUp" + | "longDown" + | "longRight" + | "longLeft" + | "blur" + | "focus" + | "pan" + | string; + eventKeyAction?: -1 | 1 | 0 | number; + tag?: number; + body?: { + state: "Began" | "Changed" | "Ended"; + x: number; + y: number; + velocityx: number; + velocityy: number; + }; + }; + + export class TVEventHandler { + enable>( + component?: T, + callback?: (component: T, data: HWEvent) => void, + ): void; + disable(): void; + } + + export interface FocusGuideProps extends ViewProps { + /** + * If the view should be "visible". display "flex" if visible, otherwise "none". Defaults to + * true + */ + enabled?: boolean; + /** + * Array of `Component`s to register as destinations with `UIFocusGuide` + */ + destinations?: (null | number | React.Component | React.ComponentClass)[]; + /** + * @deprecated Don't use it, no longer necessary. + */ + safePadding?: "both" | "vertical" | "horizontal" | null; + } + + /** + * This component provides support for Apple's `UIFocusGuide` API, to help ensure that focusable + * controls can be navigated to, even if they are not directly in line with other controls. An + * example is provided in `RNTester` that shows two different ways of using this component. + * https://github.com/react-native-tvos/react-native-tvos/blob/tvos-v0.63.4/RNTester/js/examples/TVFocusGuide/TVFocusGuideExample.js + */ + export class TVFocusGuideView extends React.Component {} + + export interface TVTextScrollViewProps extends ScrollViewProps { + /** + * The duration of the scroll animation when a swipe is detected. Default value is 0.3 s + */ + scrollDuration?: number; + /** + * Scrolling distance when a swipe is detected Default value is half the visible height + * (vertical scroller) or width (horizontal scroller) + */ + pageSize?: number; + /** + * If true, will scroll to start when focus moves out past the beginning of the scroller + * Defaults to true + */ + snapToStart?: boolean; + /** + * If true, will scroll to end when focus moves out past the end of the scroller Defaults to + * true + */ + snapToEnd?: boolean; + /** + * Called when the scroller comes into focus (e.g. for highlighting) + */ + onFocus?(evt: HWEvent): void; + /** + * Called when the scroller goes out of focus + */ + onBlur?(evt: HWEvent): void; + } + + export class TVTextScrollView extends React.Component {} + + export interface PressableStateCallbackType { + readonly focused: boolean; + } + + export interface TouchableWithoutFeedbackPropsIOS { + /** + * _(Apple TV only)_ TV preferred focus (see documentation for the View component). + * + * @platform ios + */ + hasTVPreferredFocus?: boolean; + + /** + * _(Apple TV only)_ Object with properties to control Apple TV parallax effects. + * + * Enabled: If true, parallax effects are enabled. Defaults to true. shiftDistanceX: Defaults to + * 2.0. shiftDistanceY: Defaults to 2.0. tiltAngle: Defaults to 0.05. magnification: Defaults to + * 1.0. pressMagnification: Defaults to 1.0. pressDuration: Defaults to 0.3. pressDelay: + * Defaults to 0.0. + * + * @platform ios + */ + tvParallaxProperties?: TVParallaxProperties; + } +} diff --git a/front/packages/ui/src/player/components/hover.tsx b/front/packages/ui/src/player/components/hover.tsx index e63e173b..21b8b339 100644 --- a/front/packages/ui/src/player/components/hover.tsx +++ b/front/packages/ui/src/player/components/hover.tsx @@ -35,7 +35,7 @@ import { } from "@kyoo/primitives"; import { Chapter, Font, Track } from "@kyoo/models"; import { useAtomValue, useSetAtom, useAtom } from "jotai"; -import { View, ViewProps } from "react-native"; +import { Platform, View, ViewProps } from "react-native"; import { useTranslation } from "react-i18next"; import { percent, rem, useYoshiki } from "yoshiki/native"; import { useRouter } from "solito/router"; @@ -43,6 +43,7 @@ import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; import { LeftButtons } from "./left-buttons"; import { RightButtons } from "./right-buttons"; import { bufferedAtom, durationAtom, loadAtom, playAtom, progressAtom } from "../state"; +import { useEffect, useRef } from "react"; export const Hover = ({ isLoading, @@ -74,6 +75,14 @@ export const Hover = ({ onMenuClose: () => void; show: boolean; } & ViewProps) => { + const ref = useRef(null); + + useEffect(() => { + setTimeout(() => { + ref.current?.focus(); + }, 100); + }, [show]); + // TODO animate show const opacity = !show && { opacity: 0 }; return ( @@ -113,7 +122,7 @@ export const Hover = ({ - + - + {!Platform.isTV && ( + + )} {isLoading ? ( - + ) : (

; }) => { const { css } = useYoshiki(); const { t } = useTranslation(); @@ -58,7 +61,9 @@ export const LeftButtons = ({ /> )} setPlay(!isPlaying)} {...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} {...spacing} @@ -72,7 +77,7 @@ export const LeftButtons = ({ {...spacing} /> )} - + {!Platform.isTV && } ); diff --git a/front/packages/ui/src/player/index.tsx b/front/packages/ui/src/player/index.tsx index dc3d9990..37effc90 100644 --- a/front/packages/ui/src/player/index.tsx +++ b/front/packages/ui/src/player/index.tsx @@ -21,7 +21,8 @@ import { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models"; import { Head } from "@kyoo/primitives"; import { useState, useEffect, ComponentProps } from "react"; -import { Platform, Pressable, StyleSheet } from "react-native"; +import { BackHandler, Platform, Pressable, StyleSheet } from "react-native"; +import { useTVEventHandler } from "@kyoo/primitives/tv"; import { useTranslation } from "react-i18next"; import { useRouter } from "solito/router"; import { useAtom } from "jotai"; @@ -106,6 +107,19 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => { document.addEventListener("pointermove", handler); return () => document.removeEventListener("pointermove", handler); }); + useTVEventHandler((e) => { + if (e.eventType === "cancel") setMouseMoved(false); + show(); + }); + useEffect(() => { + const handler = BackHandler.addEventListener("hardwareBackPress", () => { + if (!displayControls) return false; + setMouseMoved(false); + return true; + }); + return () => handler.remove(); + }, [displayControls]); + useEffect(() => { if (Platform.OS !== "web" || !/Mobi/i.test(window.navigator.userAgent)) return; @@ -116,7 +130,7 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => { if (error || playbackError) return ( <> - theme.appbar })} /> + theme.accent })} /> );