Update player's layout for tv

This commit is contained in:
Zoe Roux 2023-01-26 18:56:15 +09:00
parent 1bba1eb02a
commit 0e3d87a9ca
No known key found for this signature in database
GPG Key ID: B2AB52A2636E5C46
7 changed files with 289 additions and 26 deletions

View File

@ -19,8 +19,10 @@
*/ */
import { useRef, useState } from "react"; 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 { px, percent, Stylable, useYoshiki } from "yoshiki/native";
import { focusReset } from "./utils";
export const Slider = ({ export const Slider = ({
progress, progress,
@ -49,7 +51,6 @@ export const Slider = ({
const [isHover, setHover] = useState(false); const [isHover, setHover] = useState(false);
const [isFocus, setFocus] = useState(false); const [isFocus, setFocus] = useState(false);
const smallBar = !(isSeeking || isHover || isFocus); const smallBar = !(isSeeking || isHover || isFocus);
const ts = (value: number) => px(value * size); const ts = (value: number) => px(value * size);
const change = (event: GestureResponderEvent) => { const change = (event: GestureResponderEvent) => {
@ -61,16 +62,17 @@ export const Slider = ({
setProgress(Math.max(0, Math.min(locationX / layout.width, 1)) * max); 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 ( return (
<View <Container
ref={ref} ref={ref}
// @ts-ignore Web only
onMouseEnter={() => setHover(true)}
// @ts-ignore Web only
onMouseLeave={() => setHover(false)}
focusable
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
onStartShouldSetResponder={() => true} onStartShouldSetResponder={() => true}
onResponderGrant={() => { onResponderGrant={() => {
setSeek(true); setSeek(true);
@ -85,6 +87,7 @@ export const Slider = ({
onLayout={() => onLayout={() =>
ref.current?.measure((_, __, width, ___, pageX) => setLayout({ width: width, x: pageX })) ref.current?.measure((_, __, width, ___, pageX) => setLayout({ width: width, x: pageX }))
} }
// @ts-ignore Web only
onKeyDown={(e: KeyboardEvent) => { onKeyDown={(e: KeyboardEvent) => {
switch (e.code) { switch (e.code) {
case "ArrowLeft": case "ArrowLeft":
@ -107,10 +110,16 @@ export const Slider = ({
// @ts-ignore Web only // @ts-ignore Web only
cursor: "pointer", cursor: "pointer",
focus: { focus: {
shadowRadius: 0, self: focusReset,
}, },
}, },
props, {
onFocus: () => setFocus(true),
onBlur: () => setFocus(false),
onMouseEnter: () => setHover(true),
onMouseLeave: () => setHover(false),
...props,
},
)} )}
> >
<View <View
@ -174,7 +183,7 @@ export const Slider = ({
position: "absolute", position: "absolute",
top: 0, top: 0,
bottom: 0, bottom: 0,
marginY: ts(Platform.OS === "android" ? -0.5 : 0.5), marginY: ts(Platform.OS === "android" && !Platform.isTV ? -0.5 : 0.5),
bg: (theme) => theme.accent, bg: (theme) => theme.accent,
width: ts(2), width: ts(2),
height: ts(2), height: ts(2),
@ -190,6 +199,6 @@ export const Slider = ({
}, },
)} )}
/> />
</View> </Container>
); );
}; };

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
import "./tvos-type.d.ts";
export { useTVEventHandler } from "react-native";

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
export const useTVEventHandler = () => {};

178
front/packages/primitives/tvos-type.d.ts vendored Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<T extends React.Component<unknown>>(
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<any, any> | React.ComponentClass<any>)[];
/**
* @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<FocusGuideProps> {}
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<TVTextScrollViewProps> {}
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;
}
}

View File

@ -35,7 +35,7 @@ import {
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { Chapter, Font, Track } from "@kyoo/models"; import { Chapter, Font, Track } from "@kyoo/models";
import { useAtomValue, useSetAtom, useAtom } from "jotai"; 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 { useTranslation } from "react-i18next";
import { percent, rem, useYoshiki } from "yoshiki/native"; import { percent, rem, useYoshiki } from "yoshiki/native";
import { useRouter } from "solito/router"; 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 { LeftButtons } from "./left-buttons";
import { RightButtons } from "./right-buttons"; import { RightButtons } from "./right-buttons";
import { bufferedAtom, durationAtom, loadAtom, playAtom, progressAtom } from "../state"; import { bufferedAtom, durationAtom, loadAtom, playAtom, progressAtom } from "../state";
import { useEffect, useRef } from "react";
export const Hover = ({ export const Hover = ({
isLoading, isLoading,
@ -74,6 +75,14 @@ export const Hover = ({
onMenuClose: () => void; onMenuClose: () => void;
show: boolean; show: boolean;
} & ViewProps) => { } & ViewProps) => {
const ref = useRef<View | null>(null);
useEffect(() => {
setTimeout(() => {
ref.current?.focus();
}, 100);
}, [show]);
// TODO animate show // TODO animate show
const opacity = !show && { opacity: 0 }; const opacity = !show && { opacity: 0 };
return ( return (
@ -113,7 +122,7 @@ export const Hover = ({
<View <View
{...css({ flexDirection: "row", flexGrow: 1, justifyContent: "space-between" })} {...css({ flexDirection: "row", flexGrow: 1, justifyContent: "space-between" })}
> >
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} /> <LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} playRef={ref} />
<RightButtons <RightButtons
subtitles={subtitles} subtitles={subtitles}
fonts={fonts} fonts={fonts}
@ -176,14 +185,18 @@ export const Back = ({
props, props,
)} )}
> >
{!Platform.isTV && (
<IconButton <IconButton
icon={ArrowBack} icon={ArrowBack}
{...(href ? { as: Link as any, href: href } : { as: PressableFeedback, onPress: router.back })} {...(href
? { as: Link as any, href: href }
: { as: PressableFeedback, onPress: router.back })}
{...tooltip(t("player.back"))} {...tooltip(t("player.back"))}
/> />
)}
<Skeleton> <Skeleton>
{isLoading ? ( {isLoading ? (
<Skeleton {...css({ width: rem(5), })} /> <Skeleton {...css({ width: rem(5) })} />
) : ( ) : (
<H1 <H1
{...css({ {...css({

View File

@ -21,7 +21,7 @@
import { IconButton, Link, P, Slider, tooltip, ts } from "@kyoo/primitives"; import { IconButton, Link, P, Slider, tooltip, ts } from "@kyoo/primitives";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { Platform, View } from "react-native";
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg"; 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 SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
@ -32,13 +32,16 @@ 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 VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
import { px, useYoshiki } from "yoshiki/native"; import { px, useYoshiki } from "yoshiki/native";
import { RefObject } from "react";
export const LeftButtons = ({ export const LeftButtons = ({
previousSlug, previousSlug,
nextSlug, nextSlug,
playRef,
}: { }: {
previousSlug?: string | null; previousSlug?: string | null;
nextSlug?: string | null; nextSlug?: string | null;
playRef: RefObject<View>;
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
@ -58,7 +61,9 @@ export const LeftButtons = ({
/> />
)} )}
<IconButton <IconButton
ref={playRef}
icon={isPlaying ? Pause : PlayArrow} icon={isPlaying ? Pause : PlayArrow}
hasTVPreferredFocus
onPress={() => setPlay(!isPlaying)} onPress={() => setPlay(!isPlaying)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)} {...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
{...spacing} {...spacing}
@ -72,7 +77,7 @@ export const LeftButtons = ({
{...spacing} {...spacing}
/> />
)} )}
<VolumeSlider /> {!Platform.isTV && <VolumeSlider />}
<ProgressText /> <ProgressText />
</View> </View>
); );

View File

@ -21,7 +21,8 @@
import { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models"; import { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models";
import { Head } from "@kyoo/primitives"; import { Head } from "@kyoo/primitives";
import { useState, useEffect, ComponentProps } from "react"; 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 { useTranslation } from "react-i18next";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@ -106,6 +107,19 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
document.addEventListener("pointermove", handler); document.addEventListener("pointermove", handler);
return () => document.removeEventListener("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(() => { useEffect(() => {
if (Platform.OS !== "web" || !/Mobi/i.test(window.navigator.userAgent)) return; 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) if (error || playbackError)
return ( return (
<> <>
<Back isLoading={false} {...css({ position: "relative", bg: (theme) => theme.appbar })} /> <Back isLoading={false} {...css({ position: "relative", bg: (theme) => theme.accent })} />
<ErrorView error={error ?? { errors: [playbackError!] }} /> <ErrorView error={error ?? { errors: [playbackError!] }} />
</> </>
); );