mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-12-06 06:36:25 +00:00
Compare commits
11 Commits
11c300ecf7
...
feat/tv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7b2daedb9 | ||
|
|
0e3d87a9ca | ||
|
|
1bba1eb02a | ||
|
|
e45c595d6d | ||
|
|
3ccd8889f0 | ||
|
|
25b4e95128 | ||
|
|
a8a8b45f4a | ||
|
|
35a3c4c4bf | ||
|
|
249d87bda3 | ||
|
|
bf73f0ce5c | ||
|
|
b407a257c3 |
@@ -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>
|
||||
|
||||
0
front/apps/mobile/app/drawer.tsx
Normal file
0
front/apps/mobile/app/drawer.tsx
Normal 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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -41,6 +41,7 @@ const GlobalCssTheme = () => {
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
overflow: "hidden";
|
||||
background-color: ${theme.background};
|
||||
font-family: ${font.style.fontFamily};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
45
front/packages/primitives/src/button.tsx
Normal file
45
front/packages/primitives/src/button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,7 +35,6 @@ type FontList = Partial<
|
||||
>;
|
||||
|
||||
type Mode = {
|
||||
appbar: Property.Color;
|
||||
overlay0: Property.Color;
|
||||
overlay1: Property.Color;
|
||||
link: Property.Color;
|
||||
|
||||
@@ -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>{`
|
||||
|
||||
5
front/packages/primitives/src/types.d.ts
vendored
5
front/packages/primitives/src/types.d.ts
vendored
@@ -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" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
: {};
|
||||
|
||||
23
front/packages/primitives/tv.ts
Normal file
23
front/packages/primitives/tv.ts
Normal 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";
|
||||
21
front/packages/primitives/tv.web.ts
Normal file
21
front/packages/primitives/tv.web.ts
Normal 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
178
front/packages/primitives/tvos-type.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
87
front/packages/ui/src/drawer.tsx
Normal file
87
front/packages/ui/src/drawer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
1011
front/yarn.lock
1011
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user