refactor to tamagui

This commit is contained in:
Jorrin
2024-03-18 22:02:54 +01:00
parent 069c8cbb89
commit 52978f6d68
75 changed files with 5537 additions and 2988 deletions

View File

@@ -1,10 +1,9 @@
import { useRef } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { Platform } from "react-native";
import * as Haptics from "expo-haptics";
import { Tabs } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useTheme, View } from "tamagui";
import { MovieWebSvg } from "~/components/Icon";
import SvgTabBarIcon from "~/components/SvgTabBarIcon";
@@ -15,11 +14,13 @@ export default function TabLayout() {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const focusSearchInputRef = useRef(() => {});
const theme = useTheme();
return (
<SearchTabContext.Provider value={{ focusSearchInputRef }}>
<Tabs
sceneContainerStyle={{
backgroundColor: defaultTheme.extend.colors.background.main,
backgroundColor: theme.screenBackground.val,
}}
screenListeners={({ route }) => ({
tabPress: () => {
@@ -38,9 +39,9 @@ export default function TabLayout() {
})}
screenOptions={{
headerShown: false,
tabBarActiveTintColor: defaultTheme.extend.colors.tabBar.active,
tabBarActiveTintColor: theme.tabBarIconFocused.val,
tabBarStyle: {
backgroundColor: defaultTheme.extend.colors.tabBar.background,
backgroundColor: theme.tabBarBackground.val,
borderTopColor: "transparent",
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
@@ -83,10 +84,16 @@ export default function TabLayout() {
tabBarLabel: "",
tabBarIcon: ({ focused }) => (
<View
style={[
styles.searchTab,
focused ? styles.active : styles.inactive,
]}
top={2}
height={56}
width={56}
alignItems="center"
justifyContent="center"
overflow="hidden"
borderRadius={100}
backgroundColor={
focused ? theme.tabBarIconFocused : theme.tabBarIcon
}
>
<TabBarIcon name="search" color="#FFF" />
</View>
@@ -117,22 +124,3 @@ export default function TabLayout() {
</SearchTabContext.Provider>
);
}
const styles = StyleSheet.create({
searchTab: {
top: 2,
height: 56,
width: 56,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
borderRadius: 100,
textAlign: "center",
},
active: {
backgroundColor: defaultTheme.extend.colors.tabBar.active,
},
inactive: {
backgroundColor: defaultTheme.extend.colors.tabBar.inactive,
},
});

View File

@@ -1,5 +1,5 @@
import React from "react";
import { View } from "react-native";
import { Text, View } from "tamagui";
import {
bookmarks,
@@ -7,15 +7,16 @@ import {
watching,
} from "~/components/item/ItemListSection";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
export default function HomeScreen() {
return (
<View style={{ flex: 1 }}>
<View style={{ flex: 1 }} flex={1}>
<ScreenLayout
title={
<View className="flex-row items-center">
<Text className="text-2xl font-bold">Home</Text>
<View flexDirection="row" alignItems="center">
<Text fontWeight="bold" fontSize={20}>
Home
</Text>
</View>
}
>

View File

@@ -1,5 +1,6 @@
import { Text } from "tamagui";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { Text } from "~/components/ui/Text";
export default function MovieWebScreen() {
return (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Keyboard, ScrollView, View } from "react-native";
import { Keyboard, ScrollView } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
@@ -7,19 +7,14 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { Text, View } from "tamagui";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
import type { ItemData } from "~/components/item/item";
import Item from "~/components/item/item";
import {
bookmarks,
ItemListSection,
watching,
} from "~/components/item/ItemListSection";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { SearchBar } from "~/components/ui/Searchbar";
import { Text } from "~/components/ui/Text";
export default function HomeScreen() {
const [query, setQuery] = useState("");
@@ -113,36 +108,28 @@ export default function HomeScreen() {
>
<ScreenLayout
title={
<View className="flex-row items-center">
<Text className="text-2xl font-bold">Search</Text>
<View flexDirection="row" alignItems="center">
<Text fontWeight="bold" fontSize={20}>
Search
</Text>
</View>
}
>
{searchResultsLoaded ? (
{searchResultsLoaded && (
<Animated.View style={[searchResultsStyle, { flex: 1 }]}>
<View className="flex w-full flex-1 flex-row flex-wrap justify-start">
<View flexDirection="row" flexWrap="wrap">
{data?.map((item, index) => (
<View key={index} className="basis-1/2 px-3 pb-3">
<View
key={index}
paddingHorizontal={12}
paddingBottom={12}
width="50%"
>
<Item data={item} />
</View>
))}
</View>
</Animated.View>
) : (
<ScrollView
scrollEnabled={
bookmarks.length > 0 || watching.length > 0 ? true : false
}
>
<ItemListSection
title="Bookmarks"
items={bookmarks.concat(watching)}
/>
<ItemListSection
title="Continue Watching"
items={watching.concat(bookmarks)}
/>
</ScrollView>
)}
</ScreenLayout>
</ScrollView>

View File

@@ -1,22 +1,142 @@
import React, { useState } from "react";
import { Text, View } from "react-native";
import { Switch } from "react-native-paper";
import type { SelectProps } from "tamagui";
import React from "react";
import { FontAwesome, MaterialIcons } from "@expo/vector-icons";
import {
Adapt,
Label,
Select,
Separator,
Sheet,
Switch,
Text,
useTheme,
View,
XStack,
YStack,
} from "tamagui";
import type { ThemeStoreOption } from "~/stores/theme";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { useThemeStore } from "~/stores/theme";
const themeOptions: ThemeStoreOption[] = [
"main",
"blue",
"gray",
"red",
"teal",
];
export default function SettingsScreen() {
const [isSwitchOn, setIsSwitchOn] = useState(true);
const onToggleSwitch = () => setIsSwitchOn(!isSwitchOn);
return (
<ScreenLayout title="Settings">
<View className="p-4">
<Text className="mb-4 text-lg font-bold text-white">Player</Text>
<View className="flex-row items-center justify-between rounded-lg border border-white px-4 py-2">
<Text className="text-md text-white">Gesture Controls</Text>
<Switch value={isSwitchOn} onValueChange={onToggleSwitch} />
</View>
<View padding={4}>
<Text marginBottom={4} fontSize={16} fontWeight="bold" color="white">
Player
</Text>
<YStack>
<XStack width={200} alignItems="center" gap="$4">
<Label minWidth={110}>Gesture controls</Label>
<Separator minHeight={20} vertical />
<Switch size="$4" native>
<Switch.Thumb animation="quicker" />
</Switch>
</XStack>
<XStack width={200} alignItems="center" gap="$4">
<Label minWidth={110}>Theme</Label>
<Separator minHeight={20} vertical />
<ThemeSelector />
</XStack>
</YStack>
</View>
</ScreenLayout>
);
}
export function ThemeSelector(props: SelectProps) {
const theme = useTheme();
const themeStore = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
return (
<Select
value={themeStore}
onValueChange={setTheme}
disablePreventBodyScroll
native
{...props}
>
<Select.Trigger
maxWidth="$12"
iconAfter={<FontAwesome name="chevron-down" />}
>
<Select.Value />
</Select.Trigger>
<Adapt platform="native">
<Sheet
native
modal
dismissOnSnapToBottom
dismissOnOverlayPress
animationConfig={{
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
}}
snapPoints={[35]}
>
<Sheet.Handle backgroundColor="$sheetHandle" />
<Sheet.Frame backgroundColor="$sheetBackground" padding="$5">
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
</Adapt>
<Select.Content>
<Select.Viewport
animation="static"
animateOnly={["transform", "opacity"]}
enterStyle={{ o: 0, y: -10 }}
exitStyle={{ o: 0, y: 10 }}
>
{themeOptions.map((item, i) => {
return (
<Select.Item
index={i}
key={item}
value={item}
backgroundColor="$sheetItemBackground"
borderTopRightRadius={i === 0 ? "$8" : 0}
borderTopLeftRadius={i === 0 ? "$8" : 0}
borderBottomRightRadius={
i === themeOptions.length - 1 ? "$8" : 0
}
borderBottomLeftRadius={
i === themeOptions.length - 1 ? "$8" : 0
}
>
<Select.ItemText>{item}</Select.ItemText>
<Select.ItemIndicator ml="auto">
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
</Select.ItemIndicator>
</Select.Item>
);
})}
</Select.Viewport>
</Select.Content>
</Select>
);
}

View File

@@ -1,19 +1,21 @@
import { View } from "react-native";
import { Link, Stack } from "expo-router";
import { Text } from "~/components/ui/Text";
import { Text, View } from "tamagui";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<View className="flex-1 items-center justify-center p-5">
<Text className="text-lg font-bold">
This screen doesn&apos;t exist.
</Text>
<View flex={1} alignItems="center" justifyContent="center" padding={5}>
<Text fontWeight="bold">This screen doesn&apos;t exist.</Text>
<Link href="/" className="mt-4 py-4">
<Text className="text-sm text-sky-500">Go to home screen!</Text>
<Link
href="/"
style={{
marginTop: 16,
paddingVertical: 16,
}}
>
<Text color="skyblue">Go to home screen!</Text>
</Link>
</View>
</>

View File

@@ -1,26 +1,28 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { useEffect } from "react";
import { useColorScheme } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
// @ts-expect-error - No exported types
import { ModalView } from "react-native-ios-modal";
import { useFonts } from "expo-font";
import { SplashScreen, Stack } from "expo-router";
import FontAwesome from "@expo/vector-icons/FontAwesome";
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { setupNativeSheet } from "@tamagui/sheet";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TamaguiProvider, Theme, useTheme } from "tamagui";
import tamaguiConfig from "tamagui.config";
import "../styles/global.css";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useThemeStore } from "~/stores/theme";
// @ts-expect-error - Without named import it causes an infinite loop
import _styles from "../../tamagui-web.css";
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from "expo-router";
setupNativeSheet("ios", ModalView);
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: "(tabs)",
@@ -68,38 +70,50 @@ export default function RootLayout() {
);
}
function ScreenStacks() {
const theme = useTheme();
return (
<Stack
screenOptions={{
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
headerShown: false,
contentStyle: {
backgroundColor: theme.screenBackground.val,
},
}}
>
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
}}
/>
</Stack>
);
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
const themeStore = useThemeStore((s) => s.theme);
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack
screenOptions={{
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
headerShown: false,
contentStyle: {
backgroundColor: defaultTheme.extend.colors.background.main,
},
}}
>
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
autoHideHomeIndicator: true,
gestureEnabled: true,
animation: "default",
animationTypeForReplace: "push",
presentation: "card",
}}
/>
</Stack>
</ThemeProvider>
<TamaguiProvider config={tamaguiConfig} defaultTheme="main">
<ThemeProvider value={DarkTheme}>
<Theme name={themeStore}>
<ScreenStacks />
</Theme>
</ThemeProvider>
</TamaguiProvider>
</QueryClientProvider>
);
}

View File

@@ -1,8 +1,5 @@
import React from "react";
import { Text, View } from "react-native";
import { Bar as ProgressBar } from "react-native-progress";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { Progress, Text, View } from "tamagui";
export interface DownloadItemProps {
filename: string;
@@ -33,22 +30,28 @@ export const DownloadItem: React.FC<DownloadItemProps> = ({
const formattedDownloaded = formatBytes(downloaded);
return (
<View className="mb-4 rounded-lg border border-white p-4">
<Text className="mb-2 text-lg text-white">{filename}</Text>
<ProgressBar
progress={progress}
width={null}
color={defaultTheme.extend.colors.download.progressFilled}
unfilledColor={defaultTheme.extend.colors.download.progress}
borderWidth={0}
height={10}
borderRadius={5}
/>
<View className="mt-2 flex-row items-center justify-between">
<Text className="text-sm text-gray-600">
<View marginBottom={16} borderRadius={8} borderColor="white" padding={16}>
<Text marginBottom={4} fontSize={16}>
{filename}
</Text>
<Progress value={60} height={10} backgroundColor="$progressBackground">
<Progress.Indicator
animation="bounce"
backgroundColor="$progressFilled"
/>
</Progress>
<View
marginTop={8}
flexDirection="row"
alignItems="center"
justifyContent="space-between"
>
<Text fontSize={12} color="gray">
{percentage}% - {formattedDownloaded} of {formattedFileSize}
</Text>
<Text className="text-sm text-gray-600">{speed} MB/s</Text>
<Text fontSize={12} color="gray">
{speed} MB/s
</Text>
</View>
</View>
);

View File

@@ -0,0 +1,15 @@
import { Image } from "tamagui";
// TODO: Improve flag icons. This is incomplete.
export function FlagIcon({ languageCode }: { languageCode: string }) {
return (
<Image
source={{
uri: `https://flagcdn.com/w80/${languageCode.toLowerCase()}.png`,
}}
width="100%"
height="100%"
resizeMode="contain"
/>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Keyboard, ScrollView, View } from "react-native";
import { Keyboard } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
@@ -7,6 +7,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { ScrollView, Text, View } from "tamagui";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
@@ -19,7 +20,6 @@ import {
} from "~/components/item/ItemListSection";
import ScreenLayout from "~/components/layout/ScreenLayout";
import { SearchBar } from "~/components/ui/Searchbar";
import { Text } from "~/components/ui/Text";
export default function HomeScreenContent() {
const [query, setQuery] = useState("");
@@ -103,7 +103,7 @@ export default function HomeScreenContent() {
};
return (
<View style={{ flex: 1 }}>
<View flex={1}>
<ScrollView
onScrollBeginDrag={handleScrollBegin}
onMomentumScrollEnd={handleScrollEnd}
@@ -113,16 +113,23 @@ export default function HomeScreenContent() {
>
<ScreenLayout
title={
<View className="flex-row items-center">
<Text className="text-2xl font-bold">Home</Text>
<View flexDirection="row" alignItems="center">
<Text fontWeight="bold" fontSize={20}>
Home
</Text>
</View>
}
>
{searchResultsLoaded ? (
<Animated.View style={[searchResultsStyle, { flex: 1 }]}>
<View className="flex w-full flex-1 flex-row flex-wrap justify-start">
<View
width="100%"
flexWrap="wrap"
justifyContent="flex-start"
flexDirection="row"
>
{data?.map((item, index) => (
<View key={index} className="basis-1/2 px-3 pb-3">
<View key={index} flexBasis={1 / 2} paddingHorizontal={12}>
<Item data={item} />
</View>
))}

View File

@@ -1,11 +1,7 @@
import React from "react";
import Svg, { G, Path } from "react-native-svg";
export const MovieWebSvg = ({
fillColor = "currentColor",
}: {
fillColor?: string;
}) => {
export const MovieWebSvg = ({ fillColor }: { fillColor?: string }) => {
const svgPath =
"M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z";

View File

@@ -1,6 +1,6 @@
import React from "react";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useTheme } from "tamagui";
interface SvgTabBarIconProps {
focused?: boolean;
@@ -11,9 +11,8 @@ export default function SvgTabBarIcon({
focused,
children,
}: SvgTabBarIconProps) {
const fillColor = focused
? defaultTheme.extend.colors.tabBar.active
: defaultTheme.extend.colors.tabBar.inactive;
const theme = useTheme();
const fillColor = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val;
if (React.isValidElement(children)) {
return React.cloneElement(children, { fillColor } as React.Attributes);

View File

@@ -1,14 +1,13 @@
import { FontAwesome } from "@expo/vector-icons";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useTheme } from "tamagui";
type Props = {
focused?: boolean;
} & React.ComponentProps<typeof FontAwesome>;
export default function TabBarIcon({ focused, ...rest }: Props) {
const color = focused
? defaultTheme.extend.colors.tabBar.active
: defaultTheme.extend.colors.tabBar.inactive;
const theme = useTheme();
const color = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val
return <FontAwesome color={color} size={24} {...rest} />;
}

View File

@@ -1,5 +1,6 @@
import React from "react";
import { Dimensions, ScrollView, Text, View } from "react-native";
import { Dimensions } from "react-native";
import { ScrollView, Text, View } from "tamagui";
import type { ItemData } from "~/components/item/item";
import Item from "~/components/item/item";
@@ -55,7 +56,7 @@ export const ItemListSection = ({
}) => {
return (
<View>
<Text className="mb-2 mt-4 text-xl font-semibold text-white">
<Text marginBottom={8} marginTop={16} fontWeight="500" fontSize={20}>
{title}
</Text>
<ScrollView
@@ -66,11 +67,9 @@ export const ItemListSection = ({
{items.map((item, index) => (
<View
key={index}
style={{
width: itemWidth,
paddingHorizontal: padding / 2,
paddingBottom: padding,
}}
width={itemWidth}
paddingHorizontal={padding / 2}
paddingBottom={padding}
>
<Item data={item} />
</View>

View File

@@ -1,10 +1,10 @@
import type { NativeSyntheticEvent } from "react-native";
import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view";
import { Image, Keyboard, TouchableOpacity, View } from "react-native";
import { Keyboard, TouchableOpacity } from "react-native";
import ContextMenu from "react-native-context-menu-view";
import { useRouter } from "expo-router";
import { Image, Text, View } from "tamagui";
import { Text } from "~/components/ui/Text";
import { usePlayerStore } from "~/stores/player/store";
export interface ItemData {
@@ -47,19 +47,29 @@ export default function Item({ data }: { data: ItemData }) {
onLongPress={() => {}}
style={{ width: "100%" }}
>
<View className="w-full">
<View width="100%">
<ContextMenu actions={contextMenuActions} onPress={onContextMenuPress}>
<View className="mb-2 aspect-[9/14] w-full overflow-hidden rounded-2xl">
<Image source={{ uri: posterUrl }} className="h-full w-full" />
<View
marginBottom={4}
aspectRatio={9 / 14}
width="100%"
overflow="hidden"
borderRadius={24}
>
<Image source={{ uri: posterUrl }} width="100%" height="100%" />
</View>
</ContextMenu>
<Text className="font-bold">{title}</Text>
<View className="flex-row items-center gap-3">
<Text className="text-xs text-gray-600">
<Text fontWeight="bold" fontSize={14}>
{title}
</Text>
<View flexDirection="row" alignItems="center" gap={3}>
<Text fontSize={12} color="gray">
{type === "tv" ? "Show" : "Movie"}
</Text>
<View className="h-1 w-1 rounded-3xl bg-gray-600" />
<Text className="text-sm text-gray-600">{year}</Text>
<View height={1} width={1} borderRadius={24} backgroundColor="gray" />
<Text fontSize={12} color="gray">
{year}
</Text>
</View>
</View>
</TouchableOpacity>

View File

@@ -1,6 +1,4 @@
import { View } from "react-native";
import { Text } from "~/components/ui/Text";
import { Text, View } from "tamagui";
interface Props {
title?: React.ReactNode | string;
@@ -10,13 +8,17 @@ interface Props {
export default function ScreenLayout({ title, subtitle, children }: Props) {
return (
<View className="bg-shade-900 flex-1 p-12">
<View flex={1} padding={44} backgroundColor="$screenBackground">
{typeof title === "string" && (
<Text className="text-2xl font-bold">{title}</Text>
<Text fontWeight="bold" fontSize={24}>
{title}
</Text>
)}
{typeof title !== "string" && title}
<Text className="mt-1 text-sm font-bold">{subtitle}</Text>
<View className="py-3">{children}</View>
<Text fontSize={16} fontWeight="bold" marginTop={1}>
{subtitle}
</Text>
<View paddingVertical={12}>{children}</View>
</View>
);
}

View File

@@ -1,17 +1,13 @@
import { useEffect } from "react";
import { Pressable, ScrollView, View } from "react-native";
import Modal from "react-native-modal";
import { useEffect, useState } from "react";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useTheme } from "tamagui";
import { useAudioTrack } from "~/hooks/player/useAudioTrack";
import { useBoolean } from "~/hooks/useBoolean";
import { useAudioTrackStore } from "~/stores/audio";
import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button";
import { Text } from "../ui/Text";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
export interface AudioTrack {
uri: string;
@@ -21,6 +17,9 @@ export interface AudioTrack {
}
export const AudioTrackSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const tracks = usePlayerStore((state) => state.interface.audioTracks);
const setAudioTracks = usePlayerStore((state) => state.setAudioTracks);
const stream = usePlayerStore((state) => state.interface.currentStream);
@@ -30,7 +29,6 @@ export const AudioTrackSelector = () => {
(state) => state.setSelectedAudioTrack,
);
const { isTrue, on, off } = useBoolean();
const { synchronizePlayback } = useAudioTrack();
useEffect(() => {
@@ -52,58 +50,67 @@ export const AudioTrackSelector = () => {
if (!tracks?.length) return null;
return (
<View className="max-w-36 flex-1">
<>
<Controls>
<Button
title="Audio"
variant="outline"
onPress={on}
iconLeft={
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="volume-high"
name="subtitles"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
color={theme.buttonSecondaryText.val}
/>
}
/>
onPress={() => setOpen(true)}
>
Subtitles
</MWButton>
</Controls>
<Modal
isVisible={isTrue}
onBackdropPress={off}
supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<ScrollView className="flex-1 bg-gray-900">
<Text className="text-center font-bold">Select audio</Text>
{tracks?.map((track) => (
<Pressable
className="flex w-full flex-row justify-between p-3"
key={track.language}
onPress={() => {
setSelectedAudioTrack(track);
if (stream) {
void synchronizePlayback(track, stream);
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Audio"
/>
<Settings.Content>
{tracks?.map((track) => (
<Settings.Item
key={track.language}
title={track.name}
iconRight={
track.active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.playerSettingsUnactiveText.val}
/>
)
}
off();
}}
>
<Text>{track.name}</Text>
{track.active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
/>
)}
</Pressable>
))}
</ScrollView>
</Modal>
</View>
onPress={() => {
setSelectedAudioTrack(track);
if (stream) {
void synchronizePlayback(track, stream);
}
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -4,9 +4,7 @@ import { Ionicons } from "@expo/vector-icons";
import { usePlayer } from "~/hooks/player/usePlayer";
export const BackButton = ({
className,
}: Partial<React.ComponentProps<typeof Ionicons>>) => {
export const BackButton = () => {
const { dismissFullscreenPlayer } = usePlayer();
const router = useRouter();
@@ -30,7 +28,9 @@ export const BackButton = ({
}}
size={36}
color="white"
className={className}
style={{
width: 100,
}}
/>
);
};

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import { TouchableOpacity } from "react-native";
import { Text, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { Text } from "../ui/Text";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { CaptionsSelector } from "./CaptionsSelector";
import { Controls } from "./Controls";
@@ -44,13 +44,22 @@ export const BottomControls = () => {
if (status?.isLoaded) {
return (
<View className="flex h-32 w-full flex-col items-center justify-center p-6">
<View
height={128}
width="100%"
flexDirection="column"
alignItems="center"
justifyContent="center"
padding={24}
>
<Controls>
<View className="flex w-full flex-row items-center">
<Text className="font-bold">{currentTime}</Text>
<Text className="mx-1 font-bold">/</Text>
<View flexDirection="row" justifyContent="space-between" width="$11">
<Text fontWeight="bold">{currentTime}</Text>
<Text marginHorizontal={1} fontWeight="bold">
/
</Text>
<TouchableOpacity onPress={toggleTimeDisplay}>
<Text className="font-bold">
<Text fontWeight="bold">
{showRemaining ? remainingTime : durationTime}
</Text>
</TouchableOpacity>
@@ -58,7 +67,13 @@ export const BottomControls = () => {
<ProgressBar />
</Controls>
<View className="flex w-full flex-row items-center justify-center gap-4 pb-10">
<View
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={4}
paddingBottom={40}
>
<SeasonSelector />
<CaptionsSelector />
<SourceSelector />

View File

@@ -1,5 +1,4 @@
import { useMemo } from "react";
import { View } from "react-native";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
@@ -7,8 +6,8 @@ import Animated, {
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { Text, View } from "tamagui";
import { Text } from "~/components/ui/Text";
import { convertMilliSecondsToSeconds } from "~/lib/number";
import { useCaptionsStore } from "~/stores/captions";
import { usePlayerStore } from "~/stores/player/store";
@@ -74,12 +73,21 @@ export const CaptionRenderer = () => {
return (
<Animated.View
className="absolute bottom-[95px] rounded bg-black/60 px-4 py-1 text-center leading-normal"
style={animatedStyles}
style={[
{
position: "absolute",
bottom: 95,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.6)",
paddingHorizontal: 16,
paddingVertical: 8,
},
animatedStyles,
]}
>
{visibleCaptions?.map((caption) => (
<View key={caption.index}>
<Text>{caption.text}</Text>
<Text style={{ textAlign: "center" }}>{caption.text}</Text>
</View>
))}
</Animated.View>

View File

@@ -1,101 +1,153 @@
import type { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useCallback } from "react";
import { Pressable, ScrollView, View } from "react-native";
import Modal from "react-native-modal";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useState } from "react";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { useMutation } from "@tanstack/react-query";
import { parse } from "subsrt-ts";
import { useTheme, View } from "tamagui";
import type { Stream } from "@movie-web/provider-utils";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useBoolean } from "~/hooks/useBoolean";
import type { CaptionWithData } from "~/stores/captions";
import { useCaptionsStore } from "~/stores/captions";
import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button";
import { Text } from "../ui/Text";
import { FlagIcon } from "../FlagIcon";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
import { getPrettyLanguageNameFromLocale } from "./utils";
const parseCaption = async (
caption: Stream["captions"][0],
): Promise<ContentCaption[]> => {
): Promise<CaptionWithData> => {
const response = await fetch(caption.url);
const data = await response.text();
return parse(data).filter(
(cue) => cue.type === "caption",
) as ContentCaption[];
return {
...caption,
data: parse(data).filter(
(cue) => cue.type === "caption",
) as ContentCaption[],
};
};
export const CaptionsSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const captions = usePlayerStore(
(state) => state.interface.currentStream?.captions,
);
const selectedCaption = useCaptionsStore((state) => state.selectedCaption);
const setSelectedCaption = useCaptionsStore(
(state) => state.setSelectedCaption,
);
const { isTrue, on, off } = useBoolean();
const downloadAndSetCaption = useCallback(
(caption: Stream["captions"][0]) => {
parseCaption(caption)
.then((data) => {
setSelectedCaption({ ...caption, data });
})
.catch(console.error);
const downloadCaption = useMutation({
mutationKey: ["captions", selectedCaption?.id],
mutationFn: parseCaption,
onSuccess: (data) => {
setSelectedCaption(data);
},
[setSelectedCaption],
);
});
if (!captions?.length) return null;
return (
<View className="max-w-36 flex-1">
<>
<Controls>
<Button
title="Subtitles"
variant="outline"
onPress={on}
iconLeft={
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="subtitles"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
color={theme.buttonSecondaryText.val}
/>
}
/>
onPress={() => setOpen(true)}
>
Subtitles
</MWButton>
</Controls>
<Modal
isVisible={isTrue}
onBackdropPress={off}
supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<ScrollView className="flex-1 bg-gray-900">
<Text className="text-center font-bold">Select subtitle</Text>
{captions?.map((caption) => (
<Pressable
className="flex w-full flex-row justify-between p-3"
key={caption.id}
onPress={() => {
downloadAndSetCaption(caption);
off();
}}
>
<Text>{getPrettyLanguageNameFromLocale(caption.language)}</Text>
<MaterialCommunityIcons
name="download"
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialIcons
name="close"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
</Pressable>
))}
</ScrollView>
</Modal>
</View>
}
title="Subtitles"
rightButton={
<MWButton
color="$playerSettingsUnactiveText"
fontWeight="bold"
chromeless
>
Customize
</MWButton>
}
/>
<Settings.Content>
<Settings.Item
iconLeft={
<View
width="$5"
height="$3"
backgroundColor="$subtitleSelectorBackground"
borderRadius="$5"
/>
}
title={"Off"}
iconRight={
!selectedCaption?.id && (
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => setSelectedCaption(null)}
/>
{captions?.map((caption) => (
<Settings.Item
iconLeft={
<View
width="$5"
height="$3"
backgroundColor="$subtitleSelectorBackground"
borderRadius="$5"
overflow="hidden"
>
<FlagIcon languageCode={caption.language} />
</View>
}
title={getPrettyLanguageNameFromLocale(caption.language) ?? ""}
iconRight={
selectedCaption?.id === caption.id && (
<MaterialIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => downloadCaption.mutate(caption)}
key={caption.id}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -1,4 +1,4 @@
import { View } from "react-native";
import { View } from "tamagui";
import { BottomControls } from "./BottomControls";
import { Header } from "./Header";
@@ -6,7 +6,12 @@ import { MiddleControls } from "./MiddleControls";
export const ControlsOverlay = ({ isLoading }: { isLoading: boolean }) => {
return (
<View className="flex w-full flex-1 flex-col justify-between">
<View
width="100%"
flex={1}
flexDirection="column"
justifyContent="space-between"
>
<Header />
{!isLoading && <MiddleControls />}
<BottomControls />

View File

@@ -1,8 +1,7 @@
import { Image, View } from "react-native";
import { Image, Text, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import Icon from "../../../assets/images/icon-transparent.png";
import { Text } from "../ui/Text";
import { BackButton } from "./BackButton";
import { Controls } from "./Controls";
@@ -16,11 +15,20 @@ export const Header = () => {
if (!isIdle && meta) {
return (
<View className="z-50 flex h-16 w-full flex-row items-center justify-between px-6 pt-6">
<Controls>
<BackButton className="w-36" />
</Controls>
<Text className="font-bold">
<View
zIndex={50}
flexDirection="row"
alignItems="center"
justifyContent="space-between"
height={64}
paddingHorizontal="$8"
>
<View width={144}>
<Controls>
<BackButton />
</Controls>
</View>
<Text fontWeight="bold">
{meta.title} ({meta.releaseYear}){" "}
{meta.season !== undefined && meta.episode !== undefined
? mapSeasonAndEpisodeNumberToText(
@@ -29,9 +37,21 @@ export const Header = () => {
)
: ""}
</Text>
<View className="flex h-12 w-36 flex-row items-center justify-center gap-2 space-x-2 rounded-full bg-pill-background px-4 py-2 opacity-80">
<Image source={Icon} className="h-6 w-6" />
<Text className="font-bold">movie-web</Text>
<View
height={48}
width={144}
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={2}
paddingHorizontal={16}
paddingVertical={8}
opacity={0.8}
backgroundColor="$pillBackground"
borderRadius={24}
>
<Image source={Icon} height={24} width={24} />
<Text fontWeight="bold">movie-web</Text>
</View>
</View>
);

View File

@@ -1,4 +1,5 @@
import { StyleSheet, TouchableWithoutFeedback, View } from "react-native";
import { TouchableWithoutFeedback } from "react-native";
import { View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
import { Controls } from "./Controls";
@@ -15,8 +16,17 @@ export const MiddleControls = () => {
return (
<TouchableWithoutFeedback onPress={handleTouch}>
<View style={styles.container}>
<Controls className="mr-24">
<View
position="absolute"
height="100%"
width="100%"
flex={1}
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={82}
>
<Controls>
<SeekButton type="backward" />
</Controls>
<Controls>
@@ -29,16 +39,3 @@ export const MiddleControls = () => {
</TouchableWithoutFeedback>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
height: "100%",
width: "100%",
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 82,
},
});

View File

@@ -0,0 +1,54 @@
import { Button, Dialog, View } from "tamagui";
import { Controls } from "./Controls";
interface PlayerModalProps {
button: {
icon: JSX.Element;
title: string;
};
children?: React.ReactNode;
}
export function PlayerModal(props: PlayerModalProps) {
return (
<View flex={1} maxWidth={144}>
<Dialog modal>
<Dialog.Trigger asChild>
<Controls>
<Button icon={props.button.icon}>{props.button.title}</Button>
</Controls>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay
key="overlay"
animation="slow"
opacity={0.5}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Dialog.Content
bordered
elevate
key="content"
animateOnly={["transform", "opacity"]}
animation={[
"quicker",
{
opacity: {
overshootClamping: true,
},
},
]}
enterStyle={{ x: 0, y: -20, opacity: 0, scale: 0.9 }}
exitStyle={{ x: 0, y: 10, opacity: 0, scale: 0.95 }}
gap="$4"
>
{props.children}
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</View>
);
}

View File

@@ -1,71 +1,82 @@
import { Pressable, ScrollView, View } from "react-native";
import Modal from "react-native-modal";
import { useState } from "react";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useTheme } from "tamagui";
import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed";
import { useBoolean } from "~/hooks/useBoolean";
import { Button } from "../ui/Button";
import { Text } from "../ui/Text";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
export const PlaybackSpeedSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const { currentSpeed, changePlaybackSpeed } = usePlaybackSpeed();
const { isTrue, on, off } = useBoolean();
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
return (
<View className="max-w-36 flex-1">
<>
<Controls>
<Button
title="Speed"
variant="outline"
onPress={on}
iconLeft={
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="speedometer"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
color={theme.buttonSecondaryText.val}
/>
}
/>
onPress={() => setOpen(true)}
>
Playback
</MWButton>
</Controls>
<Modal
isVisible={isTrue}
onBackdropPress={off}
supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<ScrollView className="flex-1 bg-gray-900">
<Text className="text-center font-bold">Select speed</Text>
{speeds.map((speed) => (
<Pressable
className="flex w-full flex-row justify-between p-3"
key={speed}
onPress={() => {
changePlaybackSpeed(speed);
off();
}}
>
<Text>{speed}</Text>
{speed === currentSpeed && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
/>
)}
</Pressable>
))}
</ScrollView>
</Modal>
</View>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title="Playback settings"
/>
<Settings.Content>
{speeds.map((speed) => (
<Settings.Item
key={speed}
title={`${speed}x`}
iconRight={
speed === currentSpeed && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => {
changePlaybackSpeed(speed)
.then(() => setOpen(false))
.catch((err) => {
console.log("error", err);
});
}}
/>
))}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -21,7 +21,13 @@ export const ProgressBar = () => {
if (status?.isLoaded) {
return (
<TouchableOpacity
className="flex flex-1 items-center justify-center pb-8 pt-6"
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingBottom: 36,
paddingTop: 24,
}}
onPress={() => setIsIdle(false)}
>
<VideoSlider onSlidingComplete={updateProgress} />

View File

@@ -1,11 +1,7 @@
import type { ReactNode } from "react";
import React from "react";
import { StyleSheet, View } from "react-native";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { Text } from "../ui/Text";
import { Text, useTheme, View } from "tamagui";
export interface ScrapeItemProps {
status: "failure" | "pending" | "notfound" | "success" | "waiting";
@@ -37,41 +33,42 @@ export function StatusCircle({
type: ScrapeItemProps["status"];
percentage: number;
}) {
const theme = useTheme();
return (
<>
{type === "waiting" && (
<MaterialCommunityIcons
name="circle-outline"
size={40}
color={defaultTheme.extend.colors.video.scraping.noresult}
color={theme.scrapingNoResult.val}
/>
)}
{type === "pending" && (
<MaterialCommunityIcons
name={mapPercentageToIcon(percentage) as "circle-slice-1"}
size={40}
color={defaultTheme.extend.colors.video.scraping.loading}
color={theme.scrapingLoading.val}
/>
)}
{type === "failure" && (
<MaterialCommunityIcons
name="close-circle"
size={40}
color={defaultTheme.extend.colors.video.scraping.error}
color={theme.scrapingError.val}
/>
)}
{type === "notfound" && (
<MaterialIcons
name="remove-circle"
size={40}
color={defaultTheme.extend.colors.video.scraping.noresult}
color={theme.scrapingNoResult.val}
/>
)}
{type === "success" && (
<MaterialIcons
name="check-circle"
size={40}
color={defaultTheme.extend.colors.video.scraping.success}
color={theme.scrapingSuccess.val}
/>
)}
</>
@@ -82,87 +79,37 @@ export function ScrapeItem(props: ScrapeItemProps) {
const text = statusTextMap[props.status];
return (
<View style={styles.scrapeItemContainer}>
<View style={styles.itemRow}>
<View flex={1} flexDirection="column">
<View flexDirection="row" alignItems="center" gap={16}>
<StatusCircle type={props.status} percentage={props.percentage ?? 0} />
<Text
style={[
styles.itemText,
props.status === "pending"
? styles.textPending
: styles.textSecondary,
]}
fontSize={18}
color={props.status === "pending" ? "$scrapingLoading" : "white"}
>
{props.name}
</Text>
</View>
<View style={styles.textRow}>
<View style={styles.spacer} />
<View>{text && <Text style={styles.statusText}>{text}</Text>}</View>
<View flexDirection="row" alignItems="center" gap={16}>
<View width={40} />
<View>{text && <Text fontSize={18}>{text}</Text>}</View>
</View>
<View style={styles.childrenContainer}>{props.children}</View>
<View marginLeft={48}>{props.children}</View>
</View>
);
}
export function ScrapeCard(props: ScrapeCardProps) {
return (
<View style={styles.cardContainer}>
<View width={384}>
<View
style={[
styles.cardContent,
props.hasChildren ? styles.cardBackground : null,
]}
width="100%"
borderRadius={10}
paddingVertical={12}
paddingHorizontal={24}
backgroundColor={props.hasChildren ? "$scrapingCard" : "transparent"}
>
<ScrapeItem {...props} />
</View>
</View>
);
}
const styles = StyleSheet.create({
scrapeItemContainer: {
flex: 1,
flexDirection: "column",
},
itemRow: {
flexDirection: "row",
alignItems: "center",
gap: 16,
},
itemText: {
fontSize: 18,
},
textPending: {
color: "white",
},
textSecondary: {
color: "secondaryColor",
},
textRow: {
flexDirection: "row",
gap: 16,
},
spacer: {
width: 40,
},
statusText: {
marginTop: 4,
fontSize: 18,
},
childrenContainer: {
marginLeft: 48,
},
cardContainer: {
width: 384,
},
cardContent: {
width: 384,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 24,
},
cardBackground: {
backgroundColor: "cardBackgroundColor",
},
});

View File

@@ -1,7 +1,8 @@
import { useEffect, useRef } from "react";
import { SafeAreaView, View } from "react-native";
import { SafeAreaView } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import { useRouter } from "expo-router";
import { View } from "tamagui";
import type { HlsBasedStream } from "@movie-web/provider-utils";
import { extractTracksFromHLS } from "@movie-web/provider-utils";
@@ -11,7 +12,6 @@ import type { AudioTrack } from "./AudioTrackSelector";
import { useMeta } from "~/hooks/player/useMeta";
import { useScrape } from "~/hooks/player/useSourceScrape";
import { constructFullUrl } from "~/lib/url";
import { cn } from "~/lib/utils";
import { PlayerStatus } from "~/stores/player/slices/interface";
import { convertMetaToScrapeMedia } from "~/stores/player/slices/video";
import { usePlayerStore } from "~/stores/player/store";
@@ -106,17 +106,35 @@ export const ScraperProcess = ({ data }: ScraperProcessProps) => {
useEffect(() => {
scrollViewRef.current?.scrollTo({
y: currentProviderIndex * 80,
y: currentProviderIndex * 110,
animated: true,
});
}, [currentProviderIndex]);
return (
<SafeAreaView className="flex h-full flex-1 flex-col">
<View className="flex-1 items-center justify-center bg-background-main">
<SafeAreaView
style={{
display: "flex",
height: "100%",
flexDirection: "column",
flex: 1,
}}
>
<View
flex={1}
alignItems="center"
justifyContent="center"
backgroundColor="$screenBackground"
>
<ScrollView
ref={scrollViewRef}
contentContainerClassName="items-center flex flex-col py-16"
contentContainerStyle={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingVertical: 64,
}}
>
{sourceOrder.map((order) => {
const source = sources[order.id];
@@ -138,9 +156,9 @@ export const ScraperProcess = ({ data }: ScraperProcessProps) => {
percentage={source.percentage}
>
<View
className={cn({
"mt-8 space-y-6": order.children.length > 0,
})}
marginTop={order.children.length > 0 ? 8 : 0}
flexDirection="column"
gap={16}
>
{order.children.map((embedId) => {
const embed = sources[embedId];

View File

@@ -1,33 +1,25 @@
import type { SheetProps } from "tamagui";
import { useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import Modal from "react-native-modal";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useTheme, View } from "tamagui";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { fetchMediaDetails, fetchSeasonDetails } from "@movie-web/tmdb";
import { useBoolean } from "~/hooks/useBoolean";
import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button";
import { Divider } from "../ui/Divider";
import { Text } from "../ui/Text";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const EpisodeSelector = ({
seasonNumber,
setSelectedSeason,
closeModal,
}: {
...props
}: SheetProps & {
seasonNumber: number;
setSelectedSeason: (season: number | null) => void;
closeModal: () => void;
}) => {
const theme = useTheme();
const meta = usePlayerStore((state) => state.meta);
const setMeta = usePlayerStore((state) => state.setMeta);
@@ -42,38 +34,47 @@ const EpisodeSelector = ({
if (!meta) return null;
return (
<>
{isLoading && (
<View className="flex-1 items-center justify-center">
<ActivityIndicator
size="large"
color={defaultTheme.extend.colors.buttons.purple}
/>
</View>
)}
{data && (
<ScrollView
className="flex-1 flex-col bg-gray-900"
contentContainerStyle={{
padding: 10,
}}
>
<View className="flex-row items-center gap-4 p-2">
<Settings.Sheet
open={props.open}
onOpenChange={props.onOpenChange}
{...props}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame isLoading={isLoading}>
<Settings.Header
icon={
<Ionicons
name="arrow-back"
size={20}
color="white"
onPress={() => setSelectedSeason(null)}
size={24}
color={theme.buttonSecondaryText.val}
onPress={() => {
setSelectedSeason(null);
props.onOpenChange?.(false);
}}
/>
<Text className="text-center font-bold">
Season {data.season_number}
</Text>
</View>
<Divider />
{data.episodes.map((episode) => (
<TouchableOpacity
}
title={`Season ${data?.season_number}`}
/>
<Settings.Content>
{data?.episodes.map((episode) => (
<Settings.Item
key={episode.id}
className="p-3"
iconLeft={
<View
width={32}
height={32}
backgroundColor="#121c24"
justifyContent="center"
alignItems="center"
borderRadius={6}
>
<Settings.Text fontSize={14}>
E{episode.episode_number}
</Settings.Text>
</View>
}
title={episode.name}
onPress={() => {
setMeta({
...meta,
@@ -82,26 +83,23 @@ const EpisodeSelector = ({
tmdbId: episode.id.toString(),
},
});
closeModal();
}}
>
<Text>
E{episode.episode_number} {episode.name}
</Text>
</TouchableOpacity>
/>
))}
</ScrollView>
)}
</>
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};
export const SeasonSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const [episodeOpen, setEpisodeOpen] = useState(false);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const meta = usePlayerStore((state) => state.meta);
const { isTrue, on, off } = useBoolean();
const { data, isLoading } = useQuery({
queryKey: ["seasons", meta!.tmdbId],
queryFn: async () => {
@@ -113,77 +111,74 @@ export const SeasonSelector = () => {
if (meta?.type !== "show") return null;
return (
<View className="max-w-36 flex-1">
<>
<Controls>
<Button
title="Episode"
variant="outline"
onPress={on}
iconLeft={
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="audio-video"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
color={theme.buttonSecondaryText.val}
/>
}
/>
onPress={() => setOpen(true)}
>
Episodes
</MWButton>
</Controls>
<Modal
isVisible={isTrue}
onBackdropPress={off}
supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
{selectedSeason === null && (
<>
{isLoading && (
<View className="flex-1 items-center justify-center">
<ActivityIndicator
size="large"
color={defaultTheme.extend.colors.buttons.purple}
/>
</View>
)}
{data && (
<ScrollView
className="flex-1 flex-col bg-gray-900"
contentContainerStyle={{
padding: 10,
}}
>
<Text className="text-center font-bold">
{data.result.name}
</Text>
<Divider />
{data.result.seasons.map((season) => (
<TouchableOpacity
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame isLoading={isLoading}>
{episodeOpen && selectedSeason ? (
<EpisodeSelector
seasonNumber={selectedSeason}
setSelectedSeason={setSelectedSeason}
open={episodeOpen}
onOpenChange={setEpisodeOpen}
/>
) : (
<>
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
}
title={data?.result.name ?? ""}
/>
<Settings.Content>
{data?.result.seasons.map((season) => (
<Settings.Item
key={season.season_number}
className="m-1 flex flex-row items-center p-2"
onPress={() => setSelectedSeason(season.season_number)}
>
<Text className="flex-grow">
Season {season.season_number}
</Text>
<Ionicons name="chevron-forward" size={24} color="white" />
</TouchableOpacity>
title={`Season ${season.season_number}`}
iconRight={
<MaterialCommunityIcons
name="chevron-right"
size={24}
color="white"
/>
}
onPress={() => {
setSelectedSeason(season.season_number);
setEpisodeOpen(true);
}}
/>
))}
</ScrollView>
)}
</>
)}
{selectedSeason !== null && (
<EpisodeSelector
seasonNumber={selectedSeason}
setSelectedSeason={setSelectedSeason}
closeModal={off}
/>
)}
</Modal>
</View>
</Settings.Content>
</>
)}
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -1,20 +1,18 @@
import { useCallback, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import Modal from "react-native-modal";
import type { SheetProps } from "tamagui";
import { useCallback, useEffect, useState } from "react";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Spinner, Text, useTheme, View } from "tamagui";
import { getBuiltinSources, providers } from "@movie-web/provider-utils";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import {
useEmbedScrape,
useSourceScrape,
} from "~/hooks/player/useSourceScrape";
import { useBoolean } from "~/hooks/useBoolean";
import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button";
import { Text } from "../ui/Text";
import { MWButton } from "../ui/Button";
import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
const SourceItem = ({
name,
@@ -22,20 +20,39 @@ const SourceItem = ({
active,
embed,
onPress,
closeModal,
}: {
name: string;
id: string;
active?: boolean;
embed?: { url: string; embedId: string };
onPress?: (id: string) => void;
closeModal?: () => void;
}) => {
const { mutate, isPending, isError } = useEmbedScrape(closeModal);
const theme = useTheme();
const { mutate, isPending, isError } = useEmbedScrape();
return (
<Pressable
className="flex w-full flex-row justify-between p-3"
<Settings.Item
title={name}
iconRight={
<>
{active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={theme.sheetItemSelected.val}
/>
)}
{isError && (
<MaterialCommunityIcons
name="alert-circle"
size={24}
color={theme.scrapingError.val}
/>
)}
{isPending && <Spinner size="small" color="$scrapingLoading" />}
</>
}
onPress={() => {
if (onPress) {
onPress(id);
@@ -49,80 +66,86 @@ const SourceItem = ({
});
}
}}
>
<Text className="font-bold">{name}</Text>
{active && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
/>
)}
{isError && (
<MaterialCommunityIcons
name="alert-circle"
size={24}
color={defaultTheme.extend.colors.video.context.error}
/>
)}
{isPending && <ActivityIndicator size="small" color="#0000ff" />}
</Pressable>
/>
);
};
const EmbedsPart = ({
sourceId,
setCurrentScreen,
closeModal,
}: {
closeParent,
...props
}: SheetProps & {
sourceId: string;
setCurrentScreen: (screen: "source" | "embed") => void;
closeModal: () => void;
closeParent?: (open: boolean) => void;
}) => {
const { data, isPending, error } = useSourceScrape(sourceId, closeModal);
const theme = useTheme();
const { data, isPending, isError, error, status } = useSourceScrape(sourceId);
console.log(data);
useEffect(() => {
if (status === "success" && !isError && data && data?.length <= 1) {
props.onOpenChange?.(false);
closeParent?.(false);
}
}, [props.onOpenChange, status, data, isError]);
return (
<View className="flex w-full flex-col gap-4 p-3">
<View className="flex-row items-center gap-4">
<Ionicons
name="arrow-back"
size={30}
color="white"
onPress={() => setCurrentScreen("source")}
<Settings.Sheet
open={props.open}
onOpenChange={props.onOpenChange}
{...props}
>
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
<Settings.Header
icon={
<Ionicons
name="arrow-back"
size={24}
color={theme.buttonSecondaryText.val}
onPress={() => {
props.onOpenChange?.(false);
}}
/>
}
title={providers.getMetadata(sourceId)?.name ?? "Embeds"}
/>
<Text className="text-xl font-bold">Embeds</Text>
</View>
{isPending && <ActivityIndicator size="small" color="#0000ff" />}
{error && <Text>{error.message}</Text>}
{data && data?.length > 1 && (
<View className="flex w-full flex-col p-3">
{data.map((embed) => {
const metaData = providers.getMetadata(embed.embedId)!;
return (
<SourceItem
key={embed.embedId}
name={metaData.name}
id={embed.embedId}
embed={embed}
closeModal={closeModal}
/>
);
})}
</View>
)}
</View>
<Settings.Content>
<View alignItems="center" justifyContent="center">
{isPending && <Spinner size="small" color="$loadingIndicator" />}
{error && <Text>Something went wrong!</Text>}
</View>
{data && data?.length > 1 && (
<Settings.Content>
{data.map((embed) => {
const metaData = providers.getMetadata(embed.embedId)!;
return (
<SourceItem
key={embed.embedId}
name={metaData.name}
id={embed.embedId}
embed={embed}
/>
);
})}
</Settings.Content>
)}
</Settings.Content>
</Settings.SheetFrame>
</Settings.Sheet>
);
};
export const SourceSelector = () => {
const [currentScreen, setCurrentScreen] = useState<"source" | "embed">(
"source",
);
const theme = useTheme();
const [open, setOpen] = useState(false);
const [embedOpen, setEmbedOpen] = useState(false);
const sourceId = usePlayerStore((state) => state.interface.sourceId);
const setSourceId = usePlayerStore((state) => state.setSourceId);
const { isTrue, on, off } = useBoolean();
const isActive = useCallback(
(id: string) => {
return sourceId === id;
@@ -131,65 +154,71 @@ export const SourceSelector = () => {
);
return (
<View className="max-w-36">
<>
<Controls>
<Button
title="Source"
variant="outline"
onPress={on}
iconLeft={
<MWButton
type="secondary"
icon={
<MaterialCommunityIcons
name="video"
size={24}
color={defaultTheme.extend.colors.buttons.purple}
color={theme.buttonSecondaryText.val}
/>
}
/>
onPress={() => setOpen(true)}
>
Source
</MWButton>
</Controls>
<Modal
isVisible={isTrue}
onBackdropPress={off}
supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
<Settings.Sheet
forceRemoveScrollEnabled={open}
open={open}
onOpenChange={setOpen}
>
<ScrollView
className="w-full flex-1 bg-gray-900"
contentContainerStyle={{
padding: 10,
}}
>
{currentScreen === "source" && (
<Settings.SheetOverlay />
<Settings.SheetHandle />
<Settings.SheetFrame>
{embedOpen && sourceId ? (
<EmbedsPart
sourceId={sourceId}
open={embedOpen}
onOpenChange={setEmbedOpen}
closeParent={setOpen}
/>
) : (
<>
{getBuiltinSources()
.sort((a, b) => b.rank - a.rank)
.map((source) => (
<SourceItem
key={source.id}
name={source.name}
id={source.id}
active={isActive(source.id)}
onPress={() => {
setSourceId(source.id);
setCurrentScreen("embed");
}}
<Settings.Header
icon={
<MaterialCommunityIcons
name="close"
size={24}
color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/>
))}
}
title="Sources"
/>
<Settings.Content>
{getBuiltinSources()
.sort((a, b) => b.rank - a.rank)
.map((source) => (
<SourceItem
key={source.id}
name={source.name}
id={source.id}
active={isActive(source.id)}
onPress={(id) => {
setSourceId(id);
setEmbedOpen(true);
}}
/>
))}
</Settings.Content>
</>
)}
{currentScreen === "embed" && (
<EmbedsPart
sourceId={sourceId!}
setCurrentScreen={setCurrentScreen}
closeModal={off}
/>
)}
</ScrollView>
</Modal>
</View>
</Settings.SheetFrame>
</Settings.Sheet>
</>
);
};

View File

@@ -1,5 +1,4 @@
import React from "react";
import { StyleSheet, View } from "react-native";
import Animated, {
Easing,
useAnimatedProps,
@@ -8,6 +7,7 @@ import Animated, {
} from "react-native-reanimated";
import { Circle, Svg } from "react-native-svg";
import { AntDesign } from "@expo/vector-icons";
import { View } from "tamagui";
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -50,7 +50,7 @@ export const StatusCircle = ({
};
return (
<View style={styles.container}>
<View justifyContent="center" alignItems="center" position="relative">
<Svg height="60" width="60" viewBox="0 0 60 60">
{type === "loading" && (
<AnimatedCircle
@@ -70,11 +70,3 @@ export const StatusCircle = ({
</View>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: "center",
alignItems: "center",
position: "relative",
},
});

View File

@@ -1,12 +1,6 @@
import type { AVPlaybackSource } from "expo-av";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Dimensions,
Platform,
StyleSheet,
View,
} from "react-native";
import { Dimensions, Platform } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS, useSharedValue } from "react-native-reanimated";
import { ResizeMode, Video } from "expo-av";
@@ -14,6 +8,7 @@ import * as Haptics from "expo-haptics";
import * as NavigationBar from "expo-navigation-bar";
import { useRouter } from "expo-router";
import * as StatusBar from "expo-status-bar";
import { Spinner, Text, View } from "tamagui";
import { findHighestQuality } from "@movie-web/provider-utils";
@@ -24,7 +19,6 @@ import { usePlayer } from "~/hooks/player/usePlayer";
import { useVolume } from "~/hooks/player/useVolume";
import { useAudioTrackStore } from "~/stores/audio";
import { usePlayerStore } from "~/stores/player/store";
import { Text } from "../ui/Text";
import { CaptionRenderer } from "./CaptionRenderer";
import { ControlsOverlay } from "./ControlsOverlay";
import { isPointInSliderVicinity } from "./VideoSlider";
@@ -231,7 +225,13 @@ export const VideoPlayer = () => {
return (
<GestureDetector gesture={composedGesture}>
<View className="flex-1 items-center justify-center bg-black">
<View
flex={1}
flexDirection="row"
alignItems="center"
justifyContent="center"
backgroundColor="black"
>
<Video
ref={setVideoRef}
source={videoSrc}
@@ -243,8 +243,12 @@ export const VideoPlayer = () => {
onReadyForDisplay={onReadyForDisplay}
onPlaybackStatusUpdate={setStatus}
style={[
styles.video,
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
...(!isIdle && {
opacity: 0.7,
}),
@@ -252,24 +256,47 @@ export const VideoPlayer = () => {
]}
onTouchStart={() => setIsIdle(!isIdle)}
/>
<View className="h-full w-full flex-1 items-center justify-center">
<View
height="100%"
width="100%"
alignItems="center"
justifyContent="center"
>
{isLoading && (
<ActivityIndicator
<Spinner
size="large"
color="#0000ff"
className="absolute"
color="$loadingIndicator"
style={{
position: "absolute",
}}
/>
)}
<ControlsOverlay isLoading={isLoading} />
</View>
{showVolumeOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50">
<Text className="font-bold">Volume: {debouncedVolume}</Text>
<View
position="absolute"
bottom={48}
alignSelf="center"
borderRadius={999}
backgroundColor="black"
padding={12}
opacity={0.5}
>
<Text fontWeight="bold">Volume: {debouncedVolume}</Text>
</View>
)}
{showBrightnessOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50">
<Text className="font-bold">Brightness: {debouncedBrightness}</Text>
<View
position="absolute"
bottom={48}
alignSelf="center"
borderRadius={999}
backgroundColor="black"
padding={12}
opacity={0.5}
>
<Text fontWeight="bold">Brightness: {debouncedBrightness}</Text>
</View>
)}
<CaptionRenderer />
@@ -277,13 +304,3 @@ export const VideoPlayer = () => {
</GestureDetector>
);
};
const styles = StyleSheet.create({
video: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
});

View File

@@ -4,7 +4,7 @@ import type {
TapGestureHandlerEventPayload,
} from "react-native-gesture-handler";
import React, { useEffect, useRef } from "react";
import { Dimensions, StyleSheet, View } from "react-native";
import { Dimensions } from "react-native";
import {
PanGestureHandler,
State,
@@ -16,8 +16,7 @@ import Animated, {
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { useTheme, View } from "tamagui";
import { usePlayerStore } from "~/stores/player/store";
@@ -47,6 +46,7 @@ export const isPointInSliderVicinity = (x: number, y: number) => {
};
const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
const theme = useTheme();
const tapRef = useRef<TapGestureHandler>(null);
const panRef = useRef<PanGestureHandler>(null);
const status = usePlayerStore((state) => state.status);
@@ -143,13 +143,13 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
]}
>
<View
className="justify-center"
style={[
{
height: trackSize_,
borderRadius: trackSize_,
backgroundColor: defaultTheme.extend.colors.video.context.slider,
backgroundColor: theme.videoSlider.val,
width,
justifyContent: "center",
},
]}
>
@@ -158,8 +158,7 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
{
position: "absolute",
height: trackSize_,
backgroundColor:
defaultTheme.extend.colors.video.context.sliderFilled,
backgroundColor: theme.videoSliderFilled.val,
borderRadius: trackSize_ / 2,
},
progressStyle,
@@ -172,13 +171,13 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
>
<Animated.View
style={[
styles.knob,
{
justifyContent: "center",
alignItems: "center",
height: knobSize_,
width: knobSize_,
borderRadius: knobSize_ / 2,
backgroundColor:
defaultTheme.extend.colors.video.context.sliderFilled,
backgroundColor: theme.videoSliderFilled.val,
},
scrollTranslationStyle,
]}
@@ -190,11 +189,4 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
);
};
const styles = StyleSheet.create({
knob: {
justifyContent: "center",
alignItems: "center",
},
});
export default VideoSlider;

View File

@@ -0,0 +1,144 @@
import type { SheetProps, ViewProps } from "tamagui";
import {
ScrollView,
Separator,
Sheet,
Spinner,
styled,
Text,
View,
} from "tamagui";
const PlayerText = styled(Text, {
color: "$playerSettingsUnactiveText",
fontWeight: "bold",
fontSize: 18,
});
function SettingsSheet(props: SheetProps) {
return (
<Sheet
snapPoints={[90]}
dismissOnSnapToBottom
modal
animation="spring"
{...props}
>
{props.children}
</Sheet>
);
}
function SettingsSheetOverlay() {
return (
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.7)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
);
}
function SettingsSheetHandle() {
return <Sheet.Handle backgroundColor="$sheetHandle" />;
}
function SettingsSheetFrame({
children,
isLoading,
}: {
children: React.ReactNode;
isLoading?: boolean;
}) {
return (
<Sheet.Frame
backgroundColor="$playerSettingsBackground"
padding="$5"
gap="$4"
>
{isLoading && (
<Spinner
size="large"
color="$loadingIndicator"
style={{
position: "absolute",
}}
/>
)}
{!isLoading && children}
</Sheet.Frame>
);
}
function SettingsHeader({
icon,
title,
rightButton,
}: {
icon: React.ReactNode;
title: string;
rightButton?: React.ReactNode;
}) {
return (
<>
<View flexDirection="row" alignItems="center" gap="$4">
{icon}
<PlayerText flexGrow={1}>{title}</PlayerText>
{rightButton}
</View>
<Separator />
</>
);
}
function SettingsContent({
isScroll = true,
children,
}: {
isScroll?: boolean;
children: React.ReactNode;
}) {
const ViewDisplay = isScroll ? ScrollView : View;
return (
<ViewDisplay
contentContainerStyle={{
gap: "$4",
}}
>
{children}
</ViewDisplay>
);
}
function SettingsItem({
iconLeft,
iconRight,
title,
...props
}: ViewProps & {
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
title: string;
}) {
return (
<View flexDirection="row" gap="$4" alignItems="center" {...props}>
{iconLeft}
<PlayerText flexGrow={1} fontSize={16} fontWeight="700">
{title}
</PlayerText>
{iconRight}
</View>
);
}
export const Settings = {
Sheet: SettingsSheet,
SheetOverlay: SettingsSheetOverlay,
SheetHandle: SettingsSheetHandle,
SheetFrame: SettingsSheetFrame,
Header: SettingsHeader,
Content: SettingsContent,
Text: PlayerText,
Item: SettingsItem,
};

View File

@@ -1,60 +1,51 @@
import type { VariantProps } from "class-variance-authority";
import type { ReactNode } from "react";
import type { PressableProps } from "react-native";
import { Pressable } from "react-native";
import { cva } from "class-variance-authority";
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { ButtonProps } from "tamagui";
import React from "react";
import { Button, styled } from "tamagui";
import { cn } from "~/lib/utils";
import { Text } from "./Text";
const PrimaryButton = styled(Button, {
backgroundColor: "$buttonPrimaryBackground",
color: "$buttonPrimaryText",
fontWeight: "bold",
});
const buttonVariants = cva(
"flex flex-row items-center justify-center gap-4 rounded-md disabled:opacity-50",
{
variants: {
variant: {
default: "bg-buttons-purple",
outline: "border border-buttons-purple bg-transparent",
secondary: "bg-buttons-secondary",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SecondaryButton = styled(Button, {
backgroundColor: "$buttonSecondaryBackground",
color: "$buttonSecondaryText",
fontWeight: "bold",
});
export interface ButtonProps
extends PressableProps,
VariantProps<typeof buttonVariants> {
iconLeft?: ReactNode;
iconRight?: ReactNode;
title: string;
}
const PurpleButton = styled(Button, {
backgroundColor: "$buttonPurpleBackground",
color: "white",
fontWeight: "bold",
});
export function Button({
onPress,
variant,
size,
className,
iconLeft,
iconRight,
title,
}: ButtonProps) {
return (
<Pressable
onPress={onPress}
className={cn(buttonVariants({ variant, size, className }))}
>
{iconLeft}
<Text className="font-bold">{title}</Text>
{iconRight}
</Pressable>
);
}
const CancelButton = styled(Button, {
backgroundColor: "$buttonCancelBackground",
color: "white",
fontWeight: "bold",
});
export const MWButton = React.forwardRef<
typeof Button,
ButtonProps & {
type?: "primary" | "secondary" | "purple" | "cancel";
}
>((props, ref) => {
const { type, ...rest } = props;
switch (type) {
case "primary":
return <PrimaryButton {...rest} ref={ref as any} />;
case "secondary":
return <SecondaryButton {...rest} ref={ref as any} />;
case "purple":
return <PurpleButton {...rest} ref={ref as any} />;
case "cancel":
return <CancelButton {...rest} ref={ref as any} />;
default:
return <Button {...rest} ref={ref as any} />;
}
});
MWButton.displayName = "MWButton";

View File

@@ -1,5 +0,0 @@
import { View } from "react-native";
export const Divider = () => {
return <View className="mx-5 my-3 h-px border-t-0 bg-slate-600" />;
};

View File

@@ -1,18 +1,30 @@
import { useContext, useEffect, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { Keyboard } from "react-native";
import { FontAwesome5 } from "@expo/vector-icons";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { Input, styled, useTheme, View } from "tamagui";
import SearchTabContext from "./SearchTabContext";
const SearchInput = styled(Input, {
backgroundColor: "$searchBackground",
borderColor: "$colorTransparent",
placeholderTextColor: "$searchPlaceholder",
outlineStyle: "none",
focusStyle: {
borderColor: "$colorTransparent",
backgroundColor: "$searchFocused",
},
});
export function SearchBar({
onSearchChange,
}: {
onSearchChange: (text: string) => void;
}) {
const theme = useTheme();
const [keyword, setKeyword] = useState("");
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<Input>(null);
const { focusSearchInputRef } = useContext(SearchTabContext);
@@ -22,27 +34,49 @@ export function SearchBar({
};
}, [focusSearchInputRef]);
useEffect(() => {
const keyboardDidShowListener = Keyboard.addListener(
"keyboardDidShow",
() => {
setIsFocused(true);
},
);
const keyboardDidHideListener = Keyboard.addListener(
"keyboardDidHide",
() => {
setIsFocused(false);
},
);
return () => {
keyboardDidShowListener.remove();
keyboardDidHideListener.remove();
};
}, []);
const handleChange = (text: string) => {
setKeyword(text);
onSearchChange(text);
};
return (
<View className="border-primary-400 focus-within:border-primary-300 mb-6 mt-4 flex-row items-center rounded-full border bg-black">
<View className="ml-1 w-12 items-center justify-center">
<FontAwesome5
name="search"
size={18}
color={defaultTheme.extend.colors.search.icon}
/>
<View
marginBottom={12}
flexDirection="row"
alignItems="center"
borderRadius={999}
borderWidth={1}
backgroundColor={isFocused ? theme.searchFocused : theme.searchBackground}
>
<View width={48} alignItems="center" justifyContent="center">
<FontAwesome5 name="search" size={18} color={theme.searchIcon.val} />
</View>
<TextInput
<SearchInput
value={keyword}
onChangeText={handleChange}
ref={inputRef}
placeholder="What are you looking for?"
placeholderTextColor={defaultTheme.extend.colors.search.placeholder}
className="w-full rounded-3xl py-3 pr-5 text-white"
width="80%"
/>
</View>
);

View File

@@ -1,18 +0,0 @@
import type { TextProps } from "react-native";
import { Text as RNText } from "react-native";
import { cva } from "class-variance-authority";
import { cn } from "~/lib/utils";
const textVariants = cva("text-white");
export function Text({ className, ...props }: TextProps) {
return (
<RNText
className={cn(className, textVariants(), {
"font-sans": !className?.includes("font-"),
})}
{...props}
/>
);
}

View File

@@ -6,9 +6,9 @@ export const usePlaybackSpeed = () => {
const videoRef = usePlayerStore((state) => state.videoRef);
const changePlaybackSpeed = useCallback(
(newValue: number) => {
async (newValue: number) => {
if (videoRef) {
void videoRef.setRateAsync(newValue, true);
await videoRef.setRateAsync(newValue, true);
}
},
[videoRef],

View File

@@ -189,7 +189,7 @@ export function useScrape() {
};
}
export const useEmbedScrape = (closeModal?: () => void) => {
export const useEmbedScrape = () => {
const setCurrentStream = usePlayerStore((state) => state.setCurrentStream);
const queryClient = useQueryClient();
@@ -208,8 +208,8 @@ export const useEmbedScrape = (closeModal?: () => void) => {
url,
embedId,
});
if (!result) throw new Error("no result");
if (result?.stream) {
closeModal?.();
setCurrentStream(result.stream[0]!);
return result.stream;
}
@@ -224,10 +224,7 @@ export const useEmbedScrape = (closeModal?: () => void) => {
return mutate;
};
export const useSourceScrape = (
sourceId: string | null,
closeModal: () => void,
) => {
export const useSourceScrape = (sourceId: string | null) => {
const meta = usePlayerStore((state) => state.meta);
const setCurrentStream = usePlayerStore((state) => state.setCurrentStream);
const setSourceId = usePlayerStore((state) => state.setSourceId);
@@ -235,6 +232,7 @@ export const useSourceScrape = (
const query = useQuery({
queryKey: ["sourceScrape", meta, sourceId],
queryFn: async () => {
console.log("useSourceScrape", meta, sourceId);
if (!meta || !sourceId) return;
const scrapeMedia = convertMetaToScrapeMedia(meta);
const result = await getVideoStreamFromSource({
@@ -242,13 +240,13 @@ export const useSourceScrape = (
media: scrapeMedia,
events: {
update(evt) {
console.log(evt);
console.log("update useSourceScrape", evt);
},
},
});
console.log("useSourceScrape result", result);
if (result?.stream) {
closeModal();
setCurrentStream(result.stream[0]!);
setSourceId(sourceId);
return [];
@@ -256,7 +254,6 @@ export const useSourceScrape = (
if (result?.embeds.length === 1) {
const embedResult = await getVideoStreamFromEmbed(result.embeds[0]!);
if (embedResult?.stream) {
closeModal();
setCurrentStream(embedResult.stream[0]!);
setSourceId(sourceId);
return [];

View File

@@ -1,7 +0,0 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -4,7 +4,7 @@ import { immer } from "zustand/middleware/immer";
import type { Stream } from "@movie-web/provider-utils";
type CaptionWithData = Stream["captions"][0] & {
export type CaptionWithData = Stream["captions"][0] & {
data: ContentCaption[];
};

View File

@@ -0,0 +1,20 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
export type ThemeStoreOption = "main" | "blue" | "gray" | "red" | "teal";
export interface ThemeStore {
theme: ThemeStoreOption;
setTheme(v: ThemeStoreOption): void;
}
export const useThemeStore = create(
immer<ThemeStore>((set) => ({
theme: "main",
setTheme(v) {
set((s) => {
s.theme = v;
});
},
})),
);

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,21 @@
declare module "@ladjs/country-language" {
export interface LanguageObj {
countries: {
code_2: string;
code_3: string;
numCode: string;
}[];
direction: "RTL" | "LTR";
name: string[];
nativeName: string[];
iso639_1: string;
}
type Callback<T> = (err: null | string, result: null | T) => void;
declare namespace lib {
function getLanguage(locale: string, cb: Callback<LanguageObj>): void;
}
export = lib;
}

View File

@@ -1 +0,0 @@
/// <reference types="nativewind/types" />