11 Commits

Author SHA1 Message Date
Zoe Roux
c7b2daedb9 Create a tv drawer 2023-01-26 18:56:37 +09:00
Zoe Roux
0e3d87a9ca Update player's layout for tv 2023-01-26 18:56:15 +09:00
Zoe Roux
1bba1eb02a Make subtitle's menu work on tv 2023-01-26 18:55:09 +09:00
Zoe Roux
e45c595d6d Add a custom navbar for the tv 2023-01-18 22:15:48 +09:00
Zoe Roux
3ccd8889f0 Add a show more button for tv 2023-01-18 10:22:02 +09:00
Zoe Roux
25b4e95128 Add focus handling for the details page 2023-01-16 16:15:37 +09:00
Zoe Roux
a8a8b45f4a Add focus handling for the grid 2023-01-13 02:25:14 +09:00
Zoe Roux
35a3c4c4bf Fix for body scroll bars on the web on some browsers 2023-01-09 02:20:12 +09:00
Zoe Roux
249d87bda3 Install tvos package 2023-01-09 02:19:48 +09:00
Zoe Roux
bf73f0ce5c Rework Pressable to handle ripple on android 2023-01-08 21:22:20 +09:00
Zoe Roux
b407a257c3 Small cleanups 2023-01-08 19:48:39 +09:00
41 changed files with 1421 additions and 663 deletions

View File

@@ -19,8 +19,8 @@
*/
import { PortalProvider } from "@gorhom/portal";
import { ThemeSelector } from "@kyoo/primitives";
import { NavbarRight, NavbarTitle } from "@kyoo/ui";
import { ThemeSelector, ts } from "@kyoo/primitives";
import { NavbarRight, NavbarTitle, TvDrawer } from "@kyoo/ui";
import { createQueryClient } from "@kyoo/models";
import { QueryClientProvider } from "@tanstack/react-query";
import i18next from "i18next";
@@ -34,9 +34,9 @@ import {
Poppins_900Black,
} from "@expo-google-fonts/poppins";
import { useCallback, useLayoutEffect, useState } from "react";
import { useColorScheme } from "react-native";
import { Platform, useColorScheme, View } from "react-native";
import { initReactI18next } from "react-i18next";
import { useTheme } from "yoshiki/native";
import { useTheme, useYoshiki } from "yoshiki/native";
import "intl-pluralrules";
// TODO: use a backend to load jsons.
@@ -56,21 +56,39 @@ i18next.use(initReactI18next).init({
});
const ThemedStack = ({ onLayout }: { onLayout?: () => void }) => {
const theme = useTheme();
const { css, theme } = useYoshiki();
return (
<Stack
screenOptions={{
headerTitle: () => <NavbarTitle onLayout={onLayout} />,
headerRight: () => <NavbarRight />,
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: {
backgroundColor: theme.appbar,
},
headerTintColor: theme.colors.white,
}}
screenOptions={
Platform.isTV
? {
headerTitle: () => null,
headerRight: () => (
<NavbarTitle
onLayout={onLayout}
{...css({ paddingTop: ts(4), paddingRight: ts(4) })}
/>
),
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: { backgroundColor: "transparent" },
headerBackVisible: false,
headerTransparent: true,
}
: {
headerTitle: () => <NavbarTitle onLayout={onLayout} />,
headerRight: () => <NavbarRight />,
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: {
backgroundColor: theme.accent,
},
headerTintColor: theme.colors.white,
}
}
/>
);
};
@@ -85,7 +103,7 @@ export default function Root() {
useLayoutEffect(() => {
// This does not seems to work on the global scope so why not.
SplashScreen.preventAutoHideAsync();
})
});
const onLayout = useCallback(async () => {
if (fontsLoaded) {
@@ -106,7 +124,9 @@ export default function Root() {
}}
>
<PortalProvider>
<ThemedStack onLayout={onLayout} />
<TvDrawer>
<ThemedStack onLayout={onLayout} />
</TvDrawer>
</PortalProvider>
</ThemeSelector>
</QueryClientProvider>

View File

View File

@@ -34,17 +34,19 @@
"expo-updates": "~0.15.6",
"i18next": "^22.0.6",
"intl-pluralrules": "^1.3.1",
"metro": "^0.73.1",
"metro-resolver": "^0.73.1",
"moti": "^0.21.0",
"react": "18.1.0",
"react-dom": "18.1.0",
"react-i18next": "^12.0.0",
"react-native": "0.70.5",
"react-native": "npm:react-native-tvos@latest",
"react-native-reanimated": "~2.12.0",
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "~3.18.0",
"react-native-svg": "13.4.0",
"react-native-video": "alpha",
"yoshiki": "0.4.5"
"yoshiki": "1.2.1"
},
"devDependencies": {
"@babel/core": "^7.19.3",
@@ -53,6 +55,10 @@
"react-native-svg-transformer": "^1.0.0",
"typescript": "^4.6.3"
},
"overrides": {
"metro": "^0.73.1",
"metro-resolver": "^0.73.1"
},
"installConfig": {
"hoistingLimits": "workspaces"
},

View File

@@ -36,7 +36,7 @@
"react-native-web": "^0.18.10",
"solito": "^2.0.5",
"superjson": "^1.11.0",
"yoshiki": "0.4.5",
"yoshiki": "1.2.1",
"zod": "^3.19.1"
},
"devDependencies": {

View File

@@ -41,6 +41,7 @@ const GlobalCssTheme = () => {
body {
margin: 0px;
padding: 0px;
overflow: "hidden";
background-color: ${theme.background};
font-family: ${font.style.fontFamily};
}

View File

@@ -37,5 +37,9 @@
"prettier-plugin-jsdoc": "^0.4.2",
"typescript": "4.9.3"
},
"resolutions": {
"metro": "^0.73.1",
"metro-resolver": "^0.73.1"
},
"packageManager": "yarn@3.2.4"
}

View File

@@ -0,0 +1,45 @@
/*
* 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 { Theme, useYoshiki } from "yoshiki/native";
import { PressableFeedback } from "./links";
import { P } from "./text";
import { ts } from "./utils";
export const Button = ({ text, onPress }: { text: string; onPress?: () => void }) => {
const { css } = useYoshiki();
return (
<PressableFeedback
onPress={onPress}
{...css({
flexGrow: 0,
p: ts(2),
borderRadius: ts(5),
focus: {
self: { bg: (theme: Theme) => theme.accent },
text: { color: (theme: Theme) => theme.colors.white },
},
})}
>
<P {...css("text", { align: "center" })}>{text}</P>
</PressableFeedback>
);
};

View File

@@ -19,11 +19,13 @@
*/
import React, { ComponentProps, ComponentType, ForwardedRef, forwardRef } from "react";
import { Pressable, Platform, PressableProps, ViewStyle } from "react-native";
import { Platform, PressableProps, ViewStyle } from "react-native";
import { SvgProps } from "react-native-svg";
import { YoshikiStyle } from "yoshiki/dist/type";
import { px, useYoshiki } from "yoshiki/native";
import { ts } from "./utils";
import { px, Theme, useYoshiki } from "yoshiki/native";
import { PressableFeedback } from "./links";
import { alpha } from "./themes";
import { Breakpoint, ts, useBreakpointValue } from "./utils";
declare module "react" {
function forwardRef<T, P = {}>(
@@ -33,24 +35,25 @@ declare module "react" {
type IconProps = {
icon: ComponentType<SvgProps>;
color?: YoshikiStyle<string>;
color?: Breakpoint<string>;
size?: YoshikiStyle<number | string>;
};
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
const { css, theme } = useYoshiki();
const computed = css(
{ width: size, height: size, fill: color ?? theme.contrast } as ViewStyle,
props,
);
// eslint-disable-next-line react-hooks/rules-of-hooks
const colorValue = Platform.OS !== "web" ? useBreakpointValue(color) : null;
return (
<Icon
{...Platform.select<SvgProps>({
web: computed,
web: css({ width: size, height: size, fill: color ?? theme.contrast } as ViewStyle, props),
default: {
height: computed.style?.height,
width: computed.style?.width,
...computed,
height: size,
width: size,
// @ts-ignore
fill: colorValue ?? theme.contrast,
...props,
},
})}
/>
@@ -71,17 +74,23 @@ export const IconButton = forwardRef(function _IconButton<AsProps = PressablePro
) {
const { css } = useYoshiki();
const Container = as ?? Pressable;
const Container = as ?? PressableFeedback;
return (
<Container
ref={ref as any}
accessibilityRole="button"
focusRipple
{...(css(
{
p: ts(1),
m: px(2),
borderRadius: 9999,
fover: {
self: {
bg: (theme: Theme) => alpha(theme.contrast, 0.5),
},
},
},
asProps,
) as AsProps)}
@@ -98,10 +107,16 @@ export const IconFab = <AsProps = PressableProps,>(
return (
<IconButton
colors={theme.colors.black}
color={theme.colors.black}
{...(css(
{
bg: (theme) => theme.accent,
fover: {
self: {
transform: [{ scale: 1.3 }],
bg: (theme: Theme) => theme.accent,
},
},
},
props,
) as any)}

View File

@@ -33,6 +33,7 @@ export * from "./progress";
export * from "./slider";
export * from "./menu";
export * from "./input";
export * from "./button";
export * from "./animated";
export * from "./utils";

View File

@@ -18,20 +18,11 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ComponentProps, ComponentType, forwardRef, Fragment, ReactNode } from "react";
import {
Platform,
Pressable,
TextProps,
TouchableOpacity,
TouchableNativeFeedback,
View,
ViewProps,
StyleSheet,
PressableProps,
} from "react-native";
import { forwardRef, ReactNode } from "react";
import { Pressable, TextProps, View, PressableProps, Platform } from "react-native";
import { LinkCore, TextLink } from "solito/link";
import { useYoshiki } from "yoshiki/native";
import { useTheme, useYoshiki } from "yoshiki/native";
import { alpha } from "./themes";
export const A = ({
href,
@@ -59,62 +50,30 @@ export const A = ({
);
};
export const PressableFeedback = forwardRef<
unknown,
ViewProps & {
onFocus?: PressableProps["onFocus"];
onBlur?: PressableProps["onBlur"];
onPressIn?: PressableProps["onPressIn"];
onPressOut?: PressableProps["onPressOut"];
onPress?: PressableProps["onPress"];
WebComponent?: ComponentType;
}
>(function _Feedback({ children, WebComponent, ...props }, ref) {
const { onBlur, onFocus, onPressIn, onPressOut, onPress, ...noPressProps } = props;
const pressProps = { onBlur, onFocus, onPressIn, onPressOut, onPress };
const wrapperProps = Platform.select<ViewProps & { ref?: any }>({
android: {
style: {
borderRadius: StyleSheet.flatten(props?.style)?.borderRadius,
alignContent: "center",
justifyContent: "center",
overflow: "hidden",
},
ref,
},
default: {},
});
const Wrapper = wrapperProps.style ? View : Fragment;
const InnerPressable = Platform.select<ComponentType<{ children?: any }>>({
web: WebComponent ?? Pressable,
android: TouchableNativeFeedback,
ios: TouchableOpacity,
default: Pressable,
});
export const PressableFeedback = forwardRef<View, PressableProps>(
function _Feedback({ children, ...props }, ref) {
const theme = useTheme();
return (
<Wrapper {...wrapperProps}>
<InnerPressable
{...Platform.select<object>({
android: { useForeground: true, ...pressProps },
default: { ref, ...props },
})}
return (
<Pressable
ref={ref}
// TODO: Enable ripple on tv. Waiting for https://github.com/react-native-tvos/react-native-tvos/issues/440
{...(Platform.isTV
? {}
: { android_ripple: { foreground: true, color: alpha(theme.contrast, 0.5) as any } })}
{...props}
>
{Platform.select<ReactNode>({
android: <View {...noPressProps}>{children}</View>,
ios: <View {...noPressProps}>{children}</View>,
default: children,
})}
</InnerPressable>
</Wrapper>
);
});
{children}
</Pressable>
);
},
);
export const Link = ({
href,
children,
...props
}: { href: string } & Omit<ComponentProps<typeof PressableFeedback>, "WebComponent">) => {
}: { href: string; children?: ReactNode } & PressableProps) => {
return (
<LinkCore
href={href}

View File

@@ -21,8 +21,8 @@
import { Portal } from "@gorhom/portal";
import { ScrollView } from "moti";
import { ComponentType, createContext, ReactNode, useContext, useEffect, useState } from "react";
import { StyleSheet, Pressable } from "react-native";
import { percent, px, sm, useYoshiki, xl } from "yoshiki/native";
import { StyleSheet, Pressable, Platform, BackHandler } from "react-native";
import { min, percent, px, sm, Theme, useYoshiki, vh, xl } from "yoshiki/native";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import { Icon, IconButton } from "./icons";
import { PressableFeedback } from "./links";
@@ -52,6 +52,15 @@ const Menu = <AsProps,>({
else onMenuClose?.call(null);
}, [isOpen, onMenuClose, onMenuOpen]);
useEffect(() => {
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
if (!isOpen) return false;
setOpen(false);
return true;
});
return () => handler.remove();
}, [isOpen]);
return (
<>
{/* @ts-ignore */}
@@ -75,28 +84,26 @@ const Menu = <AsProps,>({
width: percent(100),
alignSelf: "center",
borderTopLeftRadius: px(26),
borderTopRightRadius: { xs: px(26), xl: 0 },
paddingTop: { xs: px(26), xl: 0 },
marginTop: { xs: px(72), xl: 0 },
borderTopRightRadius: px(26),
paddingTop: px(26),
marginTop: px(72),
},
sm({
maxWidth: px(640),
marginHorizontal: px(56),
}),
xl({
Platform.isTV && {
top: 0,
right: 0,
marginRight: 0,
borderBottomLeftRadius: px(26),
}),
maxWidth: min(px(640), vh(45)),
marginHorizontal: px(56),
borderTopRightRadius: 0,
marginTop: 0,
},
])}
>
<IconButton
icon={Close}
color={theme.colors.black}
onPress={() => setOpen(false)}
{...css({ alignSelf: "flex-end", display: { xs: "none", xl: "flex" } })}
/>
{children}
</ScrollView>
</MenuContext.Provider>
@@ -127,6 +134,7 @@ const MenuItem = ({
setOpen?.call(null, false);
onSelect?.call(null);
}}
hasTVPreferredFocus={selected}
{...css(
{
paddingHorizontal: ts(2),
@@ -134,12 +142,20 @@ const MenuItem = ({
height: ts(5),
alignItems: "center",
flexDirection: "row",
focus: {
self: {
bg: (theme: Theme) => theme.alternate.accent,
},
// text: {
// color: (theme: Theme) => theme.alternate.contrast,
// },
},
},
props as any,
)}
>
{selected && <Icon icon={Check} color={theme.paragraph} size={24} />}
<P {...css({ paddingLeft: ts(2) + +!selected * px(24) })}>{label}</P>
<P {...css(["text", { paddingLeft: ts(2) + +!selected * px(24) }])}>{label}</P>
</PressableFeedback>
);
};

View File

@@ -27,6 +27,7 @@ import { P } from "./text";
import { ContrastArea } from "./themes";
import { Icon } from "./icons";
import Dot from "@material-symbols/svg-400/rounded/fiber_manual_record-fill.svg";
import { focusReset } from "./utils";
type YoshikiFunc<T> = (props: ReturnType<typeof useYoshiki>) => T;
const YoshikiProvider = ({ children }: { children: YoshikiFunc<ReactNode> }) => {
@@ -115,18 +116,16 @@ const MenuItem = ({
<DropdownMenu.Item
onSelect={onSelect}
{...css(
[
{
display: "flex",
alignItems: "center",
padding: "8px",
height: "32px",
color: (theme) => theme.paragraph,
focus: {
boxShadow: "none",
},
{
display: "flex",
alignItems: "center",
padding: "8px",
height: "32px",
color: (theme) => theme.paragraph,
focus: {
self: focusReset,
},
],
},
props as any,
)}
>

View File

@@ -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 (
<View
<Container
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}
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,
},
)}
>
<View
@@ -174,7 +183,7 @@ export const Slider = ({
position: "absolute",
top: 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,
width: ts(2),
height: ts(2),
@@ -190,6 +199,6 @@ export const Slider = ({
},
)}
/>
</View>
</Container>
);
};

View File

@@ -24,13 +24,12 @@ import { ThemeBuilder } from "./theme";
export const catppuccin: ThemeBuilder = {
light: {
// Catppuccin latte
appbar: "#e64553",
overlay0: "#9ca0b0",
overlay1: "#7c7f93",
link: "#1e66f5",
default: {
background: "#eff1f5",
accent: "#ea76cb",
accent: "#e64553",
divider: "#8c8fa1",
heading: "#4c4f69",
paragraph: "#5c5f77",
@@ -55,13 +54,12 @@ export const catppuccin: ThemeBuilder = {
},
dark: {
// Catppuccin mocha
appbar: "#89b4fa",
overlay0: "#6c7086",
overlay1: "#9399b2",
link: "#89b4fa",
default: {
background: "#1e1e2e",
accent: "#f5c2e7",
accent: "#89b4fa",
divider: "#7f849c",
heading: "#cdd6f4",
paragraph: "#bac2de",

View File

@@ -35,7 +35,6 @@ type FontList = Partial<
>;
type Mode = {
appbar: Property.Color;
overlay0: Property.Color;
overlay1: Property.Color;
link: Property.Color;

View File

@@ -35,7 +35,7 @@ export const tooltip = (tooltip: string, up?: boolean) =>
});
export const WebTooltip = ({ theme }: { theme: Theme }) => {
const background = `${theme.colors.black}CC`;
const background = `${theme.light.colors.black}CC`;
return (
<style jsx global>{`

View File

@@ -25,6 +25,11 @@ declare module "react-native" {
interface ViewProps {
dataSet?: Record<string, string>;
}
interface PressableStateCallbackType {
readonly hovered?: boolean;
readonly focused?: boolean;
}
}
declare module "react" {

View File

@@ -19,10 +19,11 @@
*/
import { useWindowDimensions } from "react-native";
import { AtLeastOne, Breakpoints as YoshikiBreakpoint } from "yoshiki/dist/type";
import { Breakpoints as YoshikiBreakpoint } from "yoshiki/dist/type";
import { isBreakpoints } from "yoshiki/dist/utils";
import { breakpoints } from "yoshiki/native";
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
export type Breakpoint<T> = T | AtLeastOne<YoshikiBreakpoint<T>>;
// copied from yoshiki.

View File

@@ -18,8 +18,16 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Platform } from "react-native";
import { px } from "yoshiki/native";
export const ts = (spacing: number) => {
return px(spacing * 8);
};
export const focusReset: object =
Platform.OS === "web"
? {
boxShadow: "unset",
}
: {};

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

@@ -8,7 +8,7 @@
"@kyoo/primitives": "workspace:^"
},
"devDependencies": {
"@shopify/flash-list": "^1.4.0",
"@shopify/flash-list": "1.3.1",
"@types/react": "^18.0.25",
"typescript": "^4.9.3"
},

View File

@@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Link, Skeleton, Poster, ts, P, SubP } from "@kyoo/primitives";
import { Link, Skeleton, Poster, ts, focusReset, P, SubP } from "@kyoo/primitives";
import { Platform } from "react-native";
import { percent, px, Stylable, useYoshiki } from "yoshiki/native";
import { Layout, WithLoading } from "../fetch";
@@ -29,25 +29,49 @@ export const ItemGrid = ({
subtitle,
poster,
isLoading,
hasTVPreferredFocus,
...props
}: WithLoading<{
href: string;
name: string;
subtitle?: string;
poster?: string | null;
hasTVPreferredFocus?: boolean;
}> &
Stylable<"text">) => {
const { css } = useYoshiki();
const { css } = useYoshiki("grid");
return (
<Link
href={href ?? ""}
focusable={hasTVPreferredFocus || !isLoading}
accessible={hasTVPreferredFocus || !isLoading}
{...(Platform.isTV
? {
hasTVPreferredFocus: hasTVPreferredFocus,
}
: {})}
{...css(
[
{
flexDirection: "column",
alignItems: "center",
m: { xs: ts(1), sm: ts(2) },
m: { xs: ts(1), sm: ts(4) },
child: {
poster: {
borderColor: "transparent",
borderWidth: px(4),
},
},
fover: {
self: focusReset,
poster: {
borderColor: (theme) => theme.accent,
},
title: {
textDecorationLine: "underline",
},
},
},
// We leave no width on native to fill the list's grid.
Platform.OS === "web" && {
@@ -59,10 +83,16 @@ export const ItemGrid = ({
props,
)}
>
<Poster src={poster} alt={name} isLoading={isLoading} layout={{ width: percent(100) }} />
<Poster
src={poster}
alt={name}
isLoading={isLoading}
layout={{ width: percent(100) }}
{...css("poster")}
/>
<Skeleton>
{isLoading || (
<P numberOfLines={1} {...css({ marginY: 0, textAlign: "center" })}>
<P numberOfLines={1} {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
{name}
</P>
)}

View File

@@ -93,7 +93,7 @@ export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
placeholderCount={15}
layout={LayoutComponent.layout}
>
{(item) => <LayoutComponent {...itemMap(item)} />}
{(item, i) => <LayoutComponent {...itemMap(item)} hasTVPreferredFocus={i === 0} />}
</InfiniteFetch>
</>
);

View File

@@ -18,11 +18,11 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { H6, Image, Link, P, Skeleton, ts } from "@kyoo/primitives";
import { focusReset, H6, Image, Link, P, Skeleton, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Layout, WithLoading } from "../fetch";
import { percent, rem, Stylable, useYoshiki } from "yoshiki/native";
import { percent, px, rem, Stylable, Theme, useYoshiki } from "yoshiki/native";
export const episodeDisplayNumber = (
episode: {
@@ -88,6 +88,22 @@ export const EpisodeLine = ({
m: ts(1),
alignItems: "center",
flexDirection: "row",
child: {
poster: {
borderColor: "transparent",
borderWidth: px(4),
},
},
focus: {
self: focusReset,
poster: {
transform: [{ scale: 1.1 }],
borderColor: (theme: Theme) => theme.accent,
},
title: {
textDecorationLine: "underline",
},
},
},
props,
)}
@@ -102,11 +118,15 @@ export const EpisodeLine = ({
width: percent(18),
aspectRatio: 16 / 9,
}}
{...css({ flexShrink: 0, m: ts(1) })}
{...css(["poster", { flexShrink: 0, m: ts(1) }])}
/>
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
<Skeleton>
{isLoading || <H6 aria-level={undefined}>{name ?? t("show.episodeNoMetadata")}</H6>}
{isLoading || (
<H6 aria-level={undefined} {...css("title")}>
{name ?? t("show.episodeNoMetadata")}
</H6>
)}
</Skeleton>
<Skeleton>{isLoading || <P numberOfLines={3}>{overview}</P>}</Skeleton>
</View>

View File

@@ -37,10 +37,11 @@ import {
LI,
A,
ts,
Button,
} from "@kyoo/primitives";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { Platform, Pressable, PressableProps, View } from "react-native";
import {
Theme,
md,
@@ -160,7 +161,11 @@ const TitleLine = ({
as={Link}
href={`/watch/${slug}`}
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({ bg: { xs: theme.user.accent, md: theme.accent } })}
hasTVPreferredFocus
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
{...tooltip(t("show.play"))}
/>
<IconButton
@@ -190,26 +195,33 @@ const TitleLine = ({
}),
])}
>
<P
{...css({
color: (theme: Theme) => theme.user.paragraph,
display: "flex",
})}
>
{t("show.studio")}:{" "}
{isLoading ? (
<Skeleton {...css({ width: rem(5) })} />
) : (
<A href={`/studio/${studio!.slug}`} {...css({ color: (theme) => theme.user.link })}>
{studio!.name}
</A>
)}
</P>
{!Platform.isTV && (
<P
{...css({
color: (theme: Theme) => theme.user.paragraph,
display: "flex",
})}
>
{t("show.studio")}:{" "}
{isLoading ? (
<Skeleton {...css({ width: rem(5) })} />
) : (
<A href={`/studio/${studio!.slug}`} {...css({ color: (theme) => theme.user.link })}>
{studio!.name}
</A>
)}
</P>
)}
</View>
</Container>
);
};
const TvPressable = ({ children, ...props }: PressableProps) => {
if (!Platform.isTV) return <>{children}</>;
return <Pressable {...props}>{children}</Pressable>;
};
const Description = ({
isLoading,
overview,
@@ -224,59 +236,107 @@ const Description = ({
const { css } = useYoshiki();
return (
<Container {...css({ flexDirection: { xs: "column", sm: "row" } }, props)}>
<P
<Container
{...css(
{
flexDirection: Platform.isTV ? "column" : { xs: "column", sm: "row" },
paddingBottom: ts(1),
},
props,
)}
>
{!Platform.isTV && (
<P
{...css({
display: { xs: "flex", sm: "none" },
flexWrap: "wrap",
color: (theme: Theme) => theme.user.paragraph,
})}
>
{t("show.genre")}:{" "}
{(isLoading ? [...Array(3)] : genres!).map((genre, i) => (
<Fragment key={genre?.slug ?? i.toString()}>
<P>{i !== 0 && ", "}</P>
{isLoading ? (
<Skeleton {...css({ width: rem(5) })} />
) : (
<A href={`/genres/${genre.slug}`}>{genre.name}</A>
)}
</Fragment>
))}
</P>
)}
<TvPressable
{...css({
display: { xs: "flex", sm: "none" },
flexWrap: "wrap",
color: (theme: Theme) => theme.user.paragraph,
alignItems: "flex-start",
child: {
button: {
flexGrow: 0,
p: ts(2),
borderRadius: ts(5),
},
},
focus: {
button: { bg: (theme: Theme) => theme.accent },
text: { color: (theme: Theme) => theme.colors.white },
},
})}
>
{t("show.genre")}:{" "}
{(isLoading ? [...Array(3)] : genres!).map((genre, i) => (
<Fragment key={genre?.slug ?? i.toString()}>
<P>{i !== 0 && ", "}</P>
{isLoading ? (
<Skeleton {...css({ width: rem(5) })} />
<Skeleton
lines={4}
{...css({
width: percent(100),
flexBasis: 0,
flexGrow: 1,
paddingTop: Platform.isTV ? 0 : { sm: ts(4) },
})}
>
{isLoading || (
<P
{...css({
flexBasis: 0,
flexGrow: 1,
textAlign: "justify",
paddingTop: Platform.isTV ? 0 : { sm: ts(4) },
})}
>
{overview ?? t("show.noOverview")}
</P>
)}
</Skeleton>
{Platform.isTV && (
<View {...css("button")}>
<P {...css("text")}>{t("show.showMore")}</P>
</View>
)}
</TvPressable>
{!Platform.isTV && (
<>
<HR
orientation="vertical"
{...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })}
/>
<View {...css({ flexBasis: percent(25), display: { xs: "none", sm: "flex" } })}>
<H2>{t("show.genre")}</H2>
{isLoading || genres?.length ? (
<UL>
{(isLoading ? [...Array(3)] : genres!).map((genre, i) => (
<LI key={genre?.id ?? i}>
{isLoading ? (
<Skeleton {...css({ marginBottom: 0 })} />
) : (
<A href={`/genres/${genre.slug}`}>{genre.name}</A>
)}
</LI>
))}
</UL>
) : (
<A href={`/genres/${genre.slug}`}>{genre.name}</A>
<P>{t("show.genre-none")}</P>
)}
</Fragment>
))}
</P>
<Skeleton
lines={4}
{...css({ width: percent(100), flexBasis: 0, flexGrow: 1, paddingTop: ts(4) })}
>
{isLoading || (
<P {...css({ flexBasis: 0, flexGrow: 1, textAlign: "justify", paddingTop: ts(4) })}>
{overview ?? t("show.noOverview")}
</P>
)}
</Skeleton>
<HR
orientation="vertical"
{...css({ marginX: ts(2), display: { xs: "none", sm: "flex" } })}
/>
<View {...css({ flexBasis: percent(25), display: { xs: "none", sm: "flex" } })}>
<H2>{t("show.genre")}</H2>
{isLoading || genres?.length ? (
<UL>
{(isLoading ? [...Array(3)] : genres!).map((genre, i) => (
<LI key={genre?.id ?? i}>
{isLoading ? (
<Skeleton {...css({ marginBottom: 0 })} />
) : (
<A href={`/genres/${genre.slug}`}>{genre.name}</A>
)}
</LI>
))}
</UL>
) : (
<P>{t("show.genre-none")}</P>
)}
</View>
</View>
</>
)}
</Container>
);
};
@@ -284,6 +344,8 @@ const Description = ({
export const Header = ({ query, slug }: { query: QueryIdentifier<Show | Movie>; slug: string }) => {
const { css } = useYoshiki();
// TODO center elements when they are focused
return (
<Fetch query={query}>
{({ isLoading, ...data }) => (

View File

@@ -18,8 +18,8 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Episode, EpisodeP, QueryIdentifier, Season } from "@kyoo/models";
import { Container, SwitchVariant, ts } from "@kyoo/primitives";
import { Episode, EpisodeP, QueryIdentifier } from "@kyoo/models";
import { Container } from "@kyoo/primitives";
import { Stylable } from "yoshiki/native";
import { View } from "react-native";
import { InfiniteFetch } from "../fetch-infinite";

View File

@@ -20,9 +20,9 @@
import { QueryIdentifier, QueryPage, Show, ShowP } from "@kyoo/models";
import { Platform, View, ViewProps } from "react-native";
import { percent, useYoshiki, vh } from "yoshiki/native";
import { percent, useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout";
import { EpisodeList, SeasonTab } from "./season";
import { EpisodeList } from "./season";
import { Header } from "./header";
import Svg, { Path, SvgProps } from "react-native-svg";
import { Container, SwitchVariant } from "@kyoo/primitives";

View File

@@ -0,0 +1,87 @@
/*
* 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/>.
*/
/* eslint-disable react-hooks/rules-of-hooks */
import { useTranslation } from "react-i18next";
import { Platform, Pressable, View } from "react-native";
import { Theme, useYoshiki } from "yoshiki/native";
import Home from "@material-symbols/svg-400/rounded/home-fill.svg";
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
import { Icon, P, ts } from "@kyoo/primitives";
import { useState } from "react";
import { motify } from "moti";
const MotiPressable = motify(Pressable)();
const MenuItem = ({
icon,
text,
openned,
setOpen,
}: {
icon: any;
text: string;
openned: boolean;
setOpen: (value: (old: number) => number) => void;
}) => {
const { css, theme } = useYoshiki();
const [width, setWidth] = useState(0);
return (
<MotiPressable
animate={{ width }}
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
{...(css(
{
flexDirection: "row",
pX: ts(4),
focus: { self: { bg: (theme: Theme) => theme.accent } },
},
{ onFocus: () => setOpen((x) => x++), onBlur: () => setOpen((x) => x--) },
) as any)}
>
<Icon
icon={icon}
color={theme.colors.white}
size={ts(4)}
{...css({ alignSelf: "center", marginHorizontal: ts(1) })}
/>
{openned && <P {...css({ color: (theme) => theme.colors.white })}>{text}</P>}
</MotiPressable>
);
};
export const TvDrawer = ({ children }: { children: JSX.Element }) => {
if (!Platform.isTV) return children;
const { css } = useYoshiki();
const { t } = useTranslation();
const [openned, setOpen] = useState(0);
return (
<View {...css({ flexDirection: "row", flexGrow: 1, bg: (theme) => theme.contrast })}>
<View {...css({ alignSelf: "center" })}>
<MenuItem icon={Home} text={t("navbar.home")} openned={!!openned} setOpen={setOpen} />
<MenuItem icon={Search} text={t("navbar.search")} openned={!!openned} setOpen={setOpen} />
</View>
{children}
</View>
);
};

View File

@@ -66,9 +66,10 @@ export const InfiniteFetch = <Data,>({
return <EmptyView message={empty} />;
}
const placeholders = [
...Array(items ? numColumns - (items.length % numColumns) + numColumns : placeholderCount),
].map((_, i) => ({ id: `gen${i}`, isLoading: true } as Data));
const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
const placeholders = [...Array(count === 0 ? numColumns : count)].map(
(_, i) => ({ id: `gen${i}`, isLoading: true } as Data),
);
return (
<FlashList

View File

@@ -23,3 +23,4 @@ export { BrowsePage } from "./browse";
export { MovieDetails, ShowDetails } from "./details";
export { Player } from "./player";
export { SearchPage } from "./search";
export { TvDrawer } from "./drawer";

View File

@@ -23,7 +23,13 @@ import { Navbar } from "./navbar";
import { useYoshiki } from "yoshiki/native";
import { Main } from "@kyoo/primitives";
export const DefaultLayout = ({ page, transparent }: { page: ReactElement, transparent?: boolean }) => {
export const DefaultLayout = ({
page,
transparent,
}: {
page: ReactElement;
transparent?: boolean;
}) => {
const { css } = useYoshiki();
return (
@@ -36,6 +42,7 @@ export const DefaultLayout = ({ page, transparent }: { page: ReactElement, trans
top: 0,
left: 0,
right: 0,
shadowOpacity: 0,
},
)}
/>

View File

@@ -146,7 +146,7 @@ export const Navbar = (props: Stylable) => {
<Header
{...css(
{
backgroundColor: (theme) => theme.appbar,
backgroundColor: (theme) => theme.accent,
paddingX: ts(2),
height: { xs: 48, sm: 64 },
flexDirection: "row",

View File

@@ -27,6 +27,7 @@ import {
IconButton,
Link,
Poster,
PressableFeedback,
Skeleton,
Slider,
tooltip,
@@ -34,7 +35,7 @@ import {
} from "@kyoo/primitives";
import { Chapter, Font, Track } from "@kyoo/models";
import { useAtomValue, useSetAtom, useAtom } from "jotai";
import { Pressable, 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";
@@ -42,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,
@@ -73,6 +75,14 @@ export const Hover = ({
onMenuClose: () => void;
show: boolean;
} & ViewProps) => {
const ref = useRef<View | null>(null);
useEffect(() => {
setTimeout(() => {
ref.current?.focus();
}, 100);
}, [show]);
// TODO animate show
const opacity = !show && { opacity: 0 };
return (
@@ -112,7 +122,7 @@ export const Hover = ({
<View
{...css({ flexDirection: "row", flexGrow: 1, justifyContent: "space-between" })}
>
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} playRef={ref} />
<RightButtons
subtitles={subtitles}
fonts={fonts}
@@ -175,14 +185,18 @@ export const Back = ({
props,
)}
>
<IconButton
icon={ArrowBack}
{...(href ? { as: Link as any, href: href } : { as: Pressable, onPress: router.back })}
{...tooltip(t("player.back"))}
/>
{!Platform.isTV && (
<IconButton
icon={ArrowBack}
{...(href
? { as: Link as any, href: href }
: { as: PressableFeedback, onPress: router.back })}
{...tooltip(t("player.back"))}
/>
)}
<Skeleton>
{isLoading ? (
<Skeleton {...css({ width: rem(5), })} />
<Skeleton {...css({ width: rem(5) })} />
) : (
<H1
{...css({

View File

@@ -21,7 +21,7 @@
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";
import { Platform, 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";
@@ -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 { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
import { px, useYoshiki } from "yoshiki/native";
import { RefObject } from "react";
export const LeftButtons = ({
previousSlug,
nextSlug,
playRef,
}: {
previousSlug?: string | null;
nextSlug?: string | null;
playRef: RefObject<View>;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
@@ -58,7 +61,9 @@ export const LeftButtons = ({
/>
)}
<IconButton
ref={playRef}
icon={isPlaying ? Pause : PlayArrow}
hasTVPreferredFocus
onPress={() => setPlay(!isPlaying)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
{...spacing}
@@ -72,7 +77,7 @@ export const LeftButtons = ({
{...spacing}
/>
)}
<VolumeSlider />
{!Platform.isTV && <VolumeSlider />}
<ProgressText />
</View>
);

View File

@@ -28,11 +28,8 @@ import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
import { Stylable, useYoshiki } from "yoshiki/native";
import { createParam } from "solito";
import { fullscreenAtom, subtitleAtom } from "../state";
const { useParam } = createParam<{ subtitle?: string }>();
export const RightButtons = ({
subtitles,
fonts,

View File

@@ -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 (
<>
<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!] }} />
</>
);
@@ -157,23 +171,24 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
>
<Pressable
focusable={false}
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);
}
: () => (displayControls ? setMouseMoved(false) : show())
}
onPress={(e) => {
// TODO: use onPress event to diferenciate touch and click on the web (requires react native web 0.19)
if (Platform.OS !== "web") {
displayControls ? setMouseMoved(false) : show();
return;
}
e.preventDefault();
touchCount++;
if (touchCount == 2) {
touchCount = 0;
setFullscreen(!isFullscreen);
clearTimeout(touchTimeout);
} else
touchTimeout = setTimeout(() => {
touchCount = 0;
}, 400);
setPlay(!isPlaying);
}}
{...css(StyleSheet.absoluteFillObject)}
>
<Video

View File

@@ -9,7 +9,8 @@
"staff-none": "The staff is unknown",
"noOverview": "No overview available",
"episode-none": "There is no episodes in this season",
"episodeNoMetadata": "No metadata available"
"episodeNoMetadata": "No metadata available",
"showMore": "Show more"
},
"browse": {
"sortby": "Sort by {{key}}",

View File

@@ -9,7 +9,8 @@
"staff-none": "Aucun membre du staff connu",
"noOverview": "Aucune description disponible",
"episode-none": "Il n'y a pas d'épisodes dans cette saison",
"episodeNoMetadata": "Aucune metadonnée disponible"
"episodeNoMetadata": "Aucune metadonnée disponible",
"showMore": "Show more"
},
"browse": {
"sortby": "Trier par {{key}}",

File diff suppressed because it is too large Load Diff