Create menu (bottom sheet or side sheet)

This commit is contained in:
Zoe Roux 2022-12-25 19:16:34 +09:00
parent b0eb8c3b42
commit 1139a726c9
14 changed files with 268 additions and 74 deletions

View File

@ -28,6 +28,7 @@ import { createQueryClient } from "@kyoo/models";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { getLocales } from "expo-localization";
import { PortalProvider } from "@gorhom/portal";
import "intl-pluralrules";
// TODO: use a backend to load jsons.
@ -72,7 +73,9 @@ export default function Root() {
return (
<QueryClientProvider client={queryClient}>
<ThemeSelector>
<ThemedStack />
<PortalProvider>
<ThemedStack />
</PortalProvider>
</ThemeSelector>
</QueryClientProvider>
);

View File

@ -9,6 +9,7 @@
"web": "expo start --web"
},
"dependencies": {
"@gorhom/portal": "^1.0.14",
"@kyoo/ui": "workspace:^",
"@material-symbols/svg-400": "^0.4.2",
"@shopify/flash-list": "1.3.1",

View File

@ -13,6 +13,7 @@
"dependencies": {
"@emotion/react": "^11.9.3",
"@emotion/styled": "^11.9.3",
"@gorhom/portal": "^1.0.14",
"@kyoo/models": "workspace:^",
"@kyoo/primitives": "workspace:^",
"@kyoo/ui": "workspace:^",

View File

@ -20,10 +20,11 @@
import "../polyfill";
import { ReactNode, useState } from "react";
import NextApp, { AppContext, type AppProps } from "next/app";
import { PortalProvider } from "@gorhom/portal";
import { createTheme, ThemeProvider as MTheme } from "@mui/material";
import { Hydrate, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";
import NextApp, { AppContext, type AppProps } from "next/app";
import {
HiddenIfNoJs,
SkeletonCss,
@ -32,7 +33,6 @@ import {
} from "@kyoo/primitives";
import { createQueryClient, fetchQuery, QueryIdentifier, QueryPage } from "@kyoo/models";
import { useTheme, useMobileHover } from "yoshiki/web";
import { useYoshiki } from "yoshiki/native";
import superjson from "superjson";
import Head from "next/head";
import { withTranslations } from "../i18n";
@ -105,7 +105,9 @@ const App = ({ Component, pageProps }: AppProps) => {
<Hydrate state={queryState}>
<ThemeSelector>
<GlobalCssTheme />
<Layout page={<Component {...props} />} />
<PortalProvider>
<Layout page={<Component {...props} />} />
</PortalProvider>
</ThemeSelector>
</Hydrate>
</QueryClientProvider>

View File

@ -8,6 +8,7 @@
"typescript": "^4.9.3"
},
"peerDependencies": {
"@gorhom/portal": "*",
"@material-symbols/svg-400": "*",
"expo-linear-gradient": "*",
"moti": "*",

View File

@ -31,6 +31,7 @@ export * from "./container";
export * from "./divider";
export * from "./progress";
export * from "./slider";
export * from "./menu";
export * from "./animated";
export * from "./utils";

View File

@ -18,18 +18,20 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { ComponentType, Fragment, ReactNode } from "react";
import { ComponentProps, ComponentType, Fragment, ReactNode } from "react";
import {
Platform,
Pressable,
TextProps,
TouchableOpacity,
TouchableNativeFeedback,
View,
ViewProps,
StyleSheet,
PressableProps,
} from "react-native";
import { LinkCore, TextLink } from "solito/link";
import { useYoshiki, Pressable } from "yoshiki/native";
import { useYoshiki } from "yoshiki/native";
export const A = ({
href,
@ -58,19 +60,20 @@ export const A = ({
);
};
export const Link = ({
href,
export const PressableFeedback = ({
children,
WebComponent,
...props
}: ViewProps & {
href: string;
onFocus?: () => void;
onBlur?: () => void;
onPressIn?: () => void;
onPressOut?: () => void;
onFocus?: PressableProps["onFocus"];
onBlur?: PressableProps["onBlur"];
onPressIn?: PressableProps["onPressIn"];
onPressOut?: PressableProps["onPressOut"];
onPress?: PressableProps["onPress"];
WebComponent?: ComponentType;
}) => {
const { onBlur, onFocus, onPressIn, onPressOut, ...noFocusProps } = props;
const focusProps = { onBlur, onFocus, onPressIn, onPressOut };
const { onBlur, onFocus, onPressIn, onPressOut, onPress, ...noPressProps } = props;
const pressProps = { onBlur, onFocus, onPressIn, onPressOut, onPress };
const radiusStyle = Platform.select<ViewProps>({
android: {
style: { borderRadius: StyleSheet.flatten(props?.style)?.borderRadius, overflow: "hidden" },
@ -78,28 +81,43 @@ export const Link = ({
default: {},
});
const Wrapper = radiusStyle.style ? View : Fragment;
const InnerPressable = Platform.select<ComponentType<{ children?: any }>>({
web: WebComponent ?? Pressable,
android: TouchableNativeFeedback,
ios: TouchableOpacity,
default: Pressable,
});
return (
<Wrapper {...radiusStyle}>
<LinkCore
href={href}
Component={Platform.select<ComponentType>({
web: View,
android: TouchableNativeFeedback,
ios: TouchableOpacity,
default: Pressable,
})}
componentProps={Platform.select<object>({
android: { useForeground: true, ...focusProps },
<InnerPressable
{...Platform.select<object>({
android: { useForeground: true, ...pressProps },
default: props,
})}
>
{Platform.select<ReactNode>({
android: <View {...noFocusProps}>{children}</View>,
ios: <View {...noFocusProps}>{children}</View>,
android: <View {...noPressProps}>{children}</View>,
ios: <View {...noPressProps}>{children}</View>,
default: children,
})}
</LinkCore>
</InnerPressable>
</Wrapper>
);
};
export const Link = ({
href,
children,
...props
}: { href: string } & Omit<ComponentProps<typeof PressableFeedback>, "WebComponent">) => {
return (
<LinkCore
href={href}
Component={PressableFeedback}
componentProps={{ WebComponent: View, ...props }}
>
{children}
</LinkCore>
);
};

View File

@ -0,0 +1,145 @@
/*
* 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 { Portal } from "@gorhom/portal";
import { ScrollView } from "moti";
import { ComponentType, createContext, ReactNode, useContext, useEffect, useState } from "react";
import { PressableProps, StyleSheet, Pressable } from "react-native";
import { percent, px, sm, useYoshiki, xl } from "yoshiki/native";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import { IconButton } from "./icons";
import { PressableFeedback } from "./links";
import { P } from "./text";
import { ContrastArea } from "./themes";
import { ts } from "./utils";
const MenuContext = createContext<((open: boolean) => void) | undefined>(undefined);
const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
Triger,
onMenuOpen,
onMenuClose,
children,
...props
}: {
Triger: ComponentType<AsProps>;
children: ReactNode | ReactNode[] | null;
onMenuOpen: () => void;
onMenuClose: () => void;
} & Omit<AsProps, "onPress">) => {
const [isOpen, setOpen] = useState(false);
useEffect(() => {
if (isOpen) onMenuOpen?.call(null);
else onMenuClose?.call(null);
}, [isOpen, onMenuClose, onMenuOpen]);
return (
<>
{/* @ts-ignore */}
<Triger onPress={() => setOpen(true)} {...props} />
{isOpen && (
<Portal>
<ContrastArea mode="user">
{({ css, theme }) => (
<MenuContext.Provider value={setOpen}>
<Pressable
onPress={() => setOpen(false)}
focusable={false}
{...css({ ...StyleSheet.absoluteFillObject, flexGrow: 1, bg: "transparent" })}
/>
<ScrollView
{...css([
{
bg: (theme) => theme.background,
position: "absolute",
bottom: 0,
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 },
},
sm({
maxWidth: px(640),
marginHorizontal: px(56),
}),
xl({
top: 0,
right: 0,
marginRight: 0,
borderBottomLeftRadius: px(26),
}),
])}
>
<IconButton
icon={Close}
color={theme.colors.black}
onPress={() => setOpen(false)}
{...css({ alignSelf: "flex-end", display: { xs: "none", xl: "flex" } })}
/>
{children}
</ScrollView>
</MenuContext.Provider>
)}
</ContrastArea>
</Portal>
)}
</>
);
};
const MenuItem = <AsProps extends PressableProps>({
label,
icon,
selected,
as,
onPress,
...props
}: {
label: string;
icon?: JSX.Element;
selected?: boolean;
as?: ComponentType<AsProps>;
} & AsProps) => {
const { css } = useYoshiki();
const setOpen = useContext(MenuContext);
const As: ComponentType<any> = as ?? PressableFeedback;
return (
<As
onPress={(e: any) => {
setOpen?.call(null, false);
onPress?.call(null, e);
}}
{...css(
{ paddingHorizontal: ts(2), width: percent(100), height: ts(5), justifyContent: "center" },
props as any,
)}
>
{icon ?? null}
<P>{label}</P>
</As>
);
};
Menu.Item = MenuItem;
export { Menu };

View File

@ -135,7 +135,7 @@ export const ContrastArea = ({
contrastText,
}: {
children: ReactNode | YoshikiFunc<ReactNode>;
mode?: "light" | "dark";
mode?: "light" | "dark" | "user";
contrastText?: boolean;
}) => {
const oldTheme = useTheme();

View File

@ -25,10 +25,5 @@
"react-native-reanimated": "*",
"react-native-svg": "*",
"yoshiki": "*"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
}
}
}

View File

@ -19,64 +19,75 @@
*/
import { Font, Track } from "@kyoo/models";
import { IconButton, tooltip } from "@kyoo/primitives";
import { IconButton, tooltip, Menu, ts, A } from "@kyoo/primitives";
import { useAtom } from "jotai";
import { useRouter } from "solito/router";
import { useState } from "react";
import { Platform, View } from "react-native";
import { useTranslation } from "react-i18next";
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
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,
onMenuOpen,
onMenuClose,
...props
}: {
subtitles?: Track[];
fonts?: Font[];
onMenuOpen: () => void;
onMenuClose: () => void;
}) => {
} & Stylable) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const [subtitleAnchor, setSubtitleAnchor] = useState<HTMLButtonElement | null>(null);
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
const [selectedSubtitle, setSubtitle] = useParam("subtitle");
const spacing = css({ marginHorizontal: ts(1) });
subtitles = [{ id: 1, slug: "oto", displayName: "toto" }];
return (
<View
// sx={{
// display: "flex",
// "> *": {
// m: { xs: "4px !important", sm: "8px !important" },
// p: { xs: "4px !important", sm: "8px !important" },
// },
// }}
>
{/* {subtitles && ( */}
{/* <Tooltip title={t("subtitles")}> */}
{/* <IconButton */}
{/* id="sortby" */}
{/* aria-label={t("subtitles")} */}
{/* aria-controls={subtitleAnchor ? "subtitle-menu" : undefined} */}
{/* aria-haspopup="true" */}
{/* aria-expanded={subtitleAnchor ? "true" : undefined} */}
{/* onClick={(event) => { */}
{/* setSubtitleAnchor(event.currentTarget); */}
{/* onMenuOpen(); */}
{/* }} */}
{/* sx={{ color: "white" }} */}
{/* > */}
{/* <ClosedCaption /> */}
{/* </IconButton> */}
{/* </Tooltip> */}
{/* )} */}
<View {...css({ flexDirection: "row" }, props)}>
{subtitles && (
<Menu
Triger={IconButton}
icon={ClosedCaption}
{...tooltip(t("player.subtitles"), true)}
{...spacing}
// id="sortby"
// aria-controls={subtitleAnchor ? "subtitle-menu" : undefined}
// aria-haspopup="true"
// aria-expanded={subtitleAnchor ? "true" : undefined}
>
<Menu.Item
label={t("player.subtitle-none")}
selected={!selectedSubtitle}
onPress={() => setSubtitle(undefined)}
/>
{subtitles.map((x) => (
<Menu.Item
key={x.id}
label={x.displayName}
selected={selectedSubtitle === x.slug}
onPress={() => setSubtitle(x.slug)}
/>
))}
</Menu>
)}
{Platform.OS === "web" && (
<IconButton
icon={isFullscreen ? FullscreenExit : Fullscreen}
onPress={() => setFullscreen(!isFullscreen)}
{...tooltip(t("player.fullscreen"), true)}
{...spacing}
/>
)}
{/* {subtitleAnchor && ( */}

View File

@ -31,6 +31,7 @@ import {
subtitleAtom,
volumeAtom,
} from "./state";
import { Platform } from "react-native";
type Action =
| { type: "play" }
@ -93,6 +94,7 @@ export const useVideoKeyboard = (
const router = useRouter();
useEffect(() => {
if (Platform.OS !== "web") return;
const handler = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;

View File

@ -26,6 +26,7 @@ import { ResizeMode, Video as NativeVideo, VideoProps } from "expo-av";
import SubtitleOctopus from "libass-wasm";
import Hls from "hls.js";
import { bakedAtom } from "../jotai-utils";
import { Platform } from "react-native";
enum PlayMode {
Direct,
@ -100,6 +101,7 @@ export const Video = ({
const setFullscreen = useSetAtom(privateFullscreen);
useEffect(() => {
if (Platform.OS !== "web") return;
const handler = () => {
setFullscreen(document.fullscreenElement != null);
};

View File

@ -2142,6 +2142,18 @@ __metadata:
languageName: node
linkType: hard
"@gorhom/portal@npm:^1.0.14":
version: 1.0.14
resolution: "@gorhom/portal@npm:1.0.14"
dependencies:
nanoid: ^3.3.1
peerDependencies:
react: "*"
react-native: "*"
checksum: 227bb96a2db854ab29bb9da8d4f3823c7f7448358de459709dd1b78522110da564c9a8734c6bc7d7153ed7c99320e0fb5d60b420c2ebb75ecaf2f0d757f410f9
languageName: node
linkType: hard
"@graphql-typed-document-node/core@npm:^3.1.0":
version: 3.1.1
resolution: "@graphql-typed-document-node/core@npm:3.1.1"
@ -2339,6 +2351,7 @@ __metadata:
solito: ^2.0.5
typescript: ^4.9.3
peerDependencies:
"@gorhom/portal": "*"
"@material-symbols/svg-400": "*"
expo-linear-gradient: "*"
moti: "*"
@ -2375,9 +2388,6 @@ __metadata:
react-native-reanimated: "*"
react-native-svg: "*"
yoshiki: "*"
peerDependenciesMeta:
react-native:
optional: true
languageName: unknown
linkType: soft
@ -6545,19 +6555,19 @@ __metadata:
"expo-av@file:///home/anonymus-raccoon/projects/expo/packages/expo-av/::locator=mobile%40workspace%3Aapps%2Fmobile":
version: 13.0.1
resolution: "expo-av@file:///home/anonymus-raccoon/projects/expo/packages/expo-av/#///home/anonymus-raccoon/projects/expo/packages/expo-av/::hash=5981bc&locator=mobile%40workspace%3Aapps%2Fmobile"
resolution: "expo-av@file:///home/anonymus-raccoon/projects/expo/packages/expo-av/#///home/anonymus-raccoon/projects/expo/packages/expo-av/::hash=6acf81&locator=mobile%40workspace%3Aapps%2Fmobile"
peerDependencies:
expo: "*"
checksum: 3313e891f708f423f29c417372701fa6ddd5bd30028a1ebcd9634d61e5ed0475dbbcc51384fc88e82b4c0dd482dc6af38f9b1d4d103fb1a81ea05d2a4c967586
checksum: 89c23346fcf8cc3ed50b523969dad76c14e9bb3f526c7cc0032783fca9aeacc1c14a1aca86b84cb5ebd51d4ee2142f7fb507ed47d7430e12b71733858a70acc9
languageName: node
linkType: hard
"expo-av@file:///home/anonymus-raccoon/projects/expo/packages/expo-av/::locator=web%40workspace%3Aapps%2Fweb":
version: 13.0.1
resolution: "expo-av@file:///home/anonymus-raccoon/projects/expo/packages/expo-av/#///home/anonymus-raccoon/projects/expo/packages/expo-av/::hash=5981bc&locator=web%40workspace%3Aapps%2Fweb"
resolution: "expo-av@file:///home/anonymus-raccoon/projects/expo/packages/expo-av/#///home/anonymus-raccoon/projects/expo/packages/expo-av/::hash=6acf81&locator=web%40workspace%3Aapps%2Fweb"
peerDependencies:
expo: "*"
checksum: 3313e891f708f423f29c417372701fa6ddd5bd30028a1ebcd9634d61e5ed0475dbbcc51384fc88e82b4c0dd482dc6af38f9b1d4d103fb1a81ea05d2a4c967586
checksum: 89c23346fcf8cc3ed50b523969dad76c14e9bb3f526c7cc0032783fca9aeacc1c14a1aca86b84cb5ebd51d4ee2142f7fb507ed47d7430e12b71733858a70acc9
languageName: node
linkType: hard
@ -9962,6 +9972,7 @@ __metadata:
resolution: "mobile@workspace:apps/mobile"
dependencies:
"@babel/core": ^7.19.3
"@gorhom/portal": ^1.0.14
"@kyoo/ui": "workspace:^"
"@material-symbols/svg-400": ^0.4.2
"@shopify/flash-list": 1.3.1
@ -10057,7 +10068,7 @@ __metadata:
languageName: node
linkType: hard
"nanoid@npm:^3.1.23, nanoid@npm:^3.3.4":
"nanoid@npm:^3.1.23, nanoid@npm:^3.3.1, nanoid@npm:^3.3.4":
version: 3.3.4
resolution: "nanoid@npm:3.3.4"
bin:
@ -13639,6 +13650,7 @@ __metadata:
dependencies:
"@emotion/react": ^11.9.3
"@emotion/styled": ^11.9.3
"@gorhom/portal": ^1.0.14
"@kyoo/models": "workspace:^"
"@kyoo/primitives": "workspace:^"
"@kyoo/ui": "workspace:^"