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

@@ -15,20 +15,19 @@ apps
├─ Expo SDK 50 ├─ Expo SDK 50
├─ React Native using React 18 ├─ React Native using React 18
├─ Navigation using Expo Router ├─ Navigation using Expo Router
Tailwind using Nativewind Styling with Tamagui
└─ Typesafe API calls using tRPC
packages packages
├─ tmdb ├─ tmdb
| └─ Typesafe API calls to The Movie Database | └─ Typesafe API calls to The Movie Database
└─ provider-utils └─ provider-utils
└─ Typesafe API calls to the video providers └─ Typesafe API calls to the video providers
tooling tooling
├─ color
| └─ shared color palette
├─ eslint ├─ eslint
| └─ shared, fine-grained, eslint presets | └─ shared, fine-grained, eslint presets
├─ prettier ├─ prettier
| └─ shared prettier configuration | └─ shared prettier configuration
├─ tailwind
| └─ shared tailwind configuration
└─ typescript └─ typescript
└─ shared tsconfig you can extend from └─ shared tsconfig you can extend from
``` ```

View File

@@ -2,10 +2,7 @@
module.exports = function (api) { module.exports = function (api) {
api.cache(true); api.cache(true);
return { return {
presets: [ presets: ["babel-preset-expo"],
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [ plugins: [
"react-native-reanimated/plugin", "react-native-reanimated/plugin",
[ [

View File

@@ -1,19 +1,19 @@
// Learn more: https://docs.expo.dev/guides/monorepos/ // Learn more: https://docs.expo.dev/guides/monorepos/
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require("expo/metro-config");
const { FileStore } = require("metro-cache"); const { FileStore } = require("metro-cache");
const { withNativeWind } = require("nativewind/metro"); const { withTamagui } = require("@tamagui/metro-plugin");
const path = require("path"); const path = require("path");
module.exports = withTurborepoManagedCache( module.exports = withTurborepoManagedCache(
withMonorepoPaths( withMonorepoPaths(
withNativeWind( withTamagui(
getDefaultConfig(__dirname, { getDefaultConfig(__dirname, {
isCSSEnabled: true, isCSSEnabled: true,
}), }),
{ {
input: "./src/styles/global.css", components: ["tamagui"],
configPath: "./tailwind.config.ts", config: "./tamagui.config.ts",
}, },
), ),
), ),

View File

@@ -19,38 +19,42 @@
}, },
"dependencies": { "dependencies": {
"@expo/metro-config": "^0.17.3", "@expo/metro-config": "^0.17.3",
"@movie-web/colors": "*",
"@movie-web/provider-utils": "*", "@movie-web/provider-utils": "*",
"@movie-web/tmdb": "*", "@movie-web/tmdb": "*",
"@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0", "@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0",
"@react-navigation/native": "^6.1.9", "@react-navigation/native": "^6.1.9",
"@tamagui/animations-moti": "^1.91.4",
"@tamagui/babel-plugin": "^1.91.4",
"@tamagui/config": "^1.91.4",
"@tamagui/metro-plugin": "^1.91.4",
"@tanstack/react-query": "^5.22.2", "@tanstack/react-query": "^5.22.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "expo": "~50.0.13",
"expo": "~50.0.5",
"expo-av": "~13.10.5", "expo-av": "~13.10.5",
"expo-brightness": "~11.8.0", "expo-brightness": "~11.8.0",
"expo-build-properties": "~0.11.1", "expo-build-properties": "~0.11.1",
"expo-constants": "~15.4.5", "expo-constants": "~15.4.5",
"expo-haptics": "~12.8.1", "expo-haptics": "~12.8.1",
"expo-linear-gradient": "^12.7.2",
"expo-linking": "~6.2.2", "expo-linking": "~6.2.2",
"expo-navigation-bar": "^2.8.1", "expo-navigation-bar": "^2.8.1",
"expo-router": "~3.4.6", "expo-router": "~3.4.8",
"expo-screen-orientation": "~6.4.1", "expo-screen-orientation": "~6.4.1",
"expo-splash-screen": "~0.26.4", "expo-splash-screen": "~0.26.4",
"expo-status-bar": "~1.11.1", "expo-status-bar": "~1.11.1",
"expo-system-ui": "^2.9.3",
"expo-web-browser": "^12.8.2", "expo-web-browser": "^12.8.2",
"immer": "^10.0.3", "immer": "^10.0.3",
"iso-639-1": "^3.1.2", "iso-639-1": "^3.1.2",
"nativewind": "^4.0.35",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.73.2", "react-native": "0.73.5",
"react-native-context-menu-view": "^1.14.1", "react-native-context-menu-view": "^1.14.1",
"react-native-css-interop": "^0.0.35",
"react-native-gesture-handler": "~2.14.1", "react-native-gesture-handler": "~2.14.1",
"react-native-ios-modal": "^0.1.8",
"react-native-modal": "^13.0.1", "react-native-modal": "^13.0.1",
"react-native-paper": "^5.12.3", "react-native-paper": "^5.12.3",
"react-native-progress": "^5.0.1",
"react-native-quick-base64": "^2.0.8", "react-native-quick-base64": "^2.0.8",
"react-native-quick-crypto": "^0.6.1", "react-native-quick-crypto": "^0.6.1",
"react-native-reanimated": "~3.6.2", "react-native-reanimated": "~3.6.2",
@@ -60,7 +64,7 @@
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-web": "^0.19.10", "react-native-web": "^0.19.10",
"subsrt-ts": "^2.1.2", "subsrt-ts": "^2.1.2",
"tailwind-merge": "^2.2.1", "tamagui": "^1.91.4",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
@@ -69,7 +73,6 @@
"@babel/runtime": "^7.23.9", "@babel/runtime": "^7.23.9",
"@movie-web/eslint-config": "workspace:^0.2.0", "@movie-web/eslint-config": "workspace:^0.2.0",
"@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/prettier-config": "workspace:^0.1.0",
"@movie-web/tailwind-config": "workspace:^0.1.0",
"@movie-web/tsconfig": "workspace:^0.1.0", "@movie-web/tsconfig": "workspace:^0.1.0",
"@tanstack/eslint-plugin-query": "^5.20.1", "@tanstack/eslint-plugin-query": "^5.20.1",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
@@ -77,7 +80,6 @@
"babel-plugin-module-resolver": "^5.0.0", "babel-plugin-module-resolver": "^5.0.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"eslintConfig": { "eslintConfig": {

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Keyboard, ScrollView, View } from "react-native"; import { Keyboard, ScrollView } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
useAnimatedStyle, useAnimatedStyle,
@@ -7,19 +7,14 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Text, View } from "tamagui";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb"; import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
import type { ItemData } from "~/components/item/item"; import type { ItemData } from "~/components/item/item";
import Item 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 ScreenLayout from "~/components/layout/ScreenLayout";
import { SearchBar } from "~/components/ui/Searchbar"; import { SearchBar } from "~/components/ui/Searchbar";
import { Text } from "~/components/ui/Text";
export default function HomeScreen() { export default function HomeScreen() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -113,36 +108,28 @@ export default function HomeScreen() {
> >
<ScreenLayout <ScreenLayout
title={ title={
<View className="flex-row items-center"> <View flexDirection="row" alignItems="center">
<Text className="text-2xl font-bold">Search</Text> <Text fontWeight="bold" fontSize={20}>
Search
</Text>
</View> </View>
} }
> >
{searchResultsLoaded ? ( {searchResultsLoaded && (
<Animated.View style={[searchResultsStyle, { flex: 1 }]}> <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) => ( {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} /> <Item data={item} />
</View> </View>
))} ))}
</View> </View>
</Animated.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> </ScreenLayout>
</ScrollView> </ScrollView>

View File

@@ -1,22 +1,142 @@
import React, { useState } from "react"; import type { SelectProps } from "tamagui";
import { Text, View } from "react-native"; import React from "react";
import { Switch } from "react-native-paper"; 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 ScreenLayout from "~/components/layout/ScreenLayout";
import { useThemeStore } from "~/stores/theme";
const themeOptions: ThemeStoreOption[] = [
"main",
"blue",
"gray",
"red",
"teal",
];
export default function SettingsScreen() { export default function SettingsScreen() {
const [isSwitchOn, setIsSwitchOn] = useState(true);
const onToggleSwitch = () => setIsSwitchOn(!isSwitchOn);
return ( return (
<ScreenLayout title="Settings"> <ScreenLayout title="Settings">
<View className="p-4"> <View padding={4}>
<Text className="mb-4 text-lg font-bold text-white">Player</Text> <Text marginBottom={4} fontSize={16} fontWeight="bold" color="white">
<View className="flex-row items-center justify-between rounded-lg border border-white px-4 py-2"> Player
<Text className="text-md text-white">Gesture Controls</Text> </Text>
<Switch value={isSwitchOn} onValueChange={onToggleSwitch} /> <YStack>
</View> <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> </View>
</ScreenLayout> </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 { Link, Stack } from "expo-router";
import { Text, View } from "tamagui";
import { Text } from "~/components/ui/Text";
export default function NotFoundScreen() { export default function NotFoundScreen() {
return ( return (
<> <>
<Stack.Screen options={{ title: "Oops!" }} /> <Stack.Screen options={{ title: "Oops!" }} />
<View className="flex-1 items-center justify-center p-5"> <View flex={1} alignItems="center" justifyContent="center" padding={5}>
<Text className="text-lg font-bold"> <Text fontWeight="bold">This screen doesn&apos;t exist.</Text>
This screen doesn&apos;t exist.
</Text>
<Link href="/" className="mt-4 py-4"> <Link
<Text className="text-sm text-sky-500">Go to home screen!</Text> href="/"
style={{
marginTop: 16,
paddingVertical: 16,
}}
>
<Text color="skyblue">Go to home screen!</Text>
</Link> </Link>
</View> </View>
</> </>

View File

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

View File

@@ -1,8 +1,5 @@
import React from "react"; import React from "react";
import { Text, View } from "react-native"; import { Progress, Text, View } from "tamagui";
import { Bar as ProgressBar } from "react-native-progress";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
export interface DownloadItemProps { export interface DownloadItemProps {
filename: string; filename: string;
@@ -33,22 +30,28 @@ export const DownloadItem: React.FC<DownloadItemProps> = ({
const formattedDownloaded = formatBytes(downloaded); const formattedDownloaded = formatBytes(downloaded);
return ( return (
<View className="mb-4 rounded-lg border border-white p-4"> <View marginBottom={16} borderRadius={8} borderColor="white" padding={16}>
<Text className="mb-2 text-lg text-white">{filename}</Text> <Text marginBottom={4} fontSize={16}>
<ProgressBar {filename}
progress={progress} </Text>
width={null} <Progress value={60} height={10} backgroundColor="$progressBackground">
color={defaultTheme.extend.colors.download.progressFilled} <Progress.Indicator
unfilledColor={defaultTheme.extend.colors.download.progress} animation="bounce"
borderWidth={0} backgroundColor="$progressFilled"
height={10}
borderRadius={5}
/> />
<View className="mt-2 flex-row items-center justify-between"> </Progress>
<Text className="text-sm text-gray-600"> <View
marginTop={8}
flexDirection="row"
alignItems="center"
justifyContent="space-between"
>
<Text fontSize={12} color="gray">
{percentage}% - {formattedDownloaded} of {formattedFileSize} {percentage}% - {formattedDownloaded} of {formattedFileSize}
</Text> </Text>
<Text className="text-sm text-gray-600">{speed} MB/s</Text> <Text fontSize={12} color="gray">
{speed} MB/s
</Text>
</View> </View>
</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 React, { useEffect, useState } from "react";
import { Keyboard, ScrollView, View } from "react-native"; import { Keyboard } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
useAnimatedStyle, useAnimatedStyle,
@@ -7,6 +7,7 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ScrollView, Text, View } from "tamagui";
import { getMediaPoster, searchTitle } from "@movie-web/tmdb"; import { getMediaPoster, searchTitle } from "@movie-web/tmdb";
@@ -19,7 +20,6 @@ import {
} from "~/components/item/ItemListSection"; } from "~/components/item/ItemListSection";
import ScreenLayout from "~/components/layout/ScreenLayout"; import ScreenLayout from "~/components/layout/ScreenLayout";
import { SearchBar } from "~/components/ui/Searchbar"; import { SearchBar } from "~/components/ui/Searchbar";
import { Text } from "~/components/ui/Text";
export default function HomeScreenContent() { export default function HomeScreenContent() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -103,7 +103,7 @@ export default function HomeScreenContent() {
}; };
return ( return (
<View style={{ flex: 1 }}> <View flex={1}>
<ScrollView <ScrollView
onScrollBeginDrag={handleScrollBegin} onScrollBeginDrag={handleScrollBegin}
onMomentumScrollEnd={handleScrollEnd} onMomentumScrollEnd={handleScrollEnd}
@@ -113,16 +113,23 @@ export default function HomeScreenContent() {
> >
<ScreenLayout <ScreenLayout
title={ title={
<View className="flex-row items-center"> <View flexDirection="row" alignItems="center">
<Text className="text-2xl font-bold">Home</Text> <Text fontWeight="bold" fontSize={20}>
Home
</Text>
</View> </View>
} }
> >
{searchResultsLoaded ? ( {searchResultsLoaded ? (
<Animated.View style={[searchResultsStyle, { flex: 1 }]}> <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) => ( {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} /> <Item data={item} />
</View> </View>
))} ))}

View File

@@ -1,11 +1,7 @@
import React from "react"; import React from "react";
import Svg, { G, Path } from "react-native-svg"; import Svg, { G, Path } from "react-native-svg";
export const MovieWebSvg = ({ export const MovieWebSvg = ({ fillColor }: { fillColor?: string }) => {
fillColor = "currentColor",
}: {
fillColor?: string;
}) => {
const svgPath = 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"; "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 React from "react";
import { defaultTheme } from "@movie-web/tailwind-config/themes"; import { useTheme } from "tamagui";
interface SvgTabBarIconProps { interface SvgTabBarIconProps {
focused?: boolean; focused?: boolean;
@@ -11,9 +11,8 @@ export default function SvgTabBarIcon({
focused, focused,
children, children,
}: SvgTabBarIconProps) { }: SvgTabBarIconProps) {
const fillColor = focused const theme = useTheme();
? defaultTheme.extend.colors.tabBar.active const fillColor = focused ? theme.tabBarIconFocused.val : theme.tabBarIcon.val;
: defaultTheme.extend.colors.tabBar.inactive;
if (React.isValidElement(children)) { if (React.isValidElement(children)) {
return React.cloneElement(children, { fillColor } as React.Attributes); return React.cloneElement(children, { fillColor } as React.Attributes);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from "react"; 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 { usePlayerStore } from "~/stores/player/store";
import { Text } from "../ui/Text";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { CaptionsSelector } from "./CaptionsSelector"; import { CaptionsSelector } from "./CaptionsSelector";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
@@ -44,13 +44,22 @@ export const BottomControls = () => {
if (status?.isLoaded) { if (status?.isLoaded) {
return ( 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> <Controls>
<View className="flex w-full flex-row items-center"> <View flexDirection="row" justifyContent="space-between" width="$11">
<Text className="font-bold">{currentTime}</Text> <Text fontWeight="bold">{currentTime}</Text>
<Text className="mx-1 font-bold">/</Text> <Text marginHorizontal={1} fontWeight="bold">
/
</Text>
<TouchableOpacity onPress={toggleTimeDisplay}> <TouchableOpacity onPress={toggleTimeDisplay}>
<Text className="font-bold"> <Text fontWeight="bold">
{showRemaining ? remainingTime : durationTime} {showRemaining ? remainingTime : durationTime}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -58,7 +67,13 @@ export const BottomControls = () => {
<ProgressBar /> <ProgressBar />
</Controls> </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 /> <SeasonSelector />
<CaptionsSelector /> <CaptionsSelector />
<SourceSelector /> <SourceSelector />

View File

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

View File

@@ -1,101 +1,153 @@
import type { ContentCaption } from "subsrt-ts/dist/types/handler"; import type { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useCallback } from "react"; import { useState } from "react";
import { Pressable, ScrollView, View } from "react-native"; import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import Modal from "react-native-modal"; import { useMutation } from "@tanstack/react-query";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { parse } from "subsrt-ts"; import { parse } from "subsrt-ts";
import { useTheme, View } from "tamagui";
import type { Stream } from "@movie-web/provider-utils"; 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 { useCaptionsStore } from "~/stores/captions";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
import { Button } from "../ui/Button"; import { FlagIcon } from "../FlagIcon";
import { Text } from "../ui/Text"; import { MWButton } from "../ui/Button";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
import { getPrettyLanguageNameFromLocale } from "./utils"; import { getPrettyLanguageNameFromLocale } from "./utils";
const parseCaption = async ( const parseCaption = async (
caption: Stream["captions"][0], caption: Stream["captions"][0],
): Promise<ContentCaption[]> => { ): Promise<CaptionWithData> => {
const response = await fetch(caption.url); const response = await fetch(caption.url);
const data = await response.text(); const data = await response.text();
return parse(data).filter( return {
...caption,
data: parse(data).filter(
(cue) => cue.type === "caption", (cue) => cue.type === "caption",
) as ContentCaption[]; ) as ContentCaption[],
};
}; };
export const CaptionsSelector = () => { export const CaptionsSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const captions = usePlayerStore( const captions = usePlayerStore(
(state) => state.interface.currentStream?.captions, (state) => state.interface.currentStream?.captions,
); );
const selectedCaption = useCaptionsStore((state) => state.selectedCaption);
const setSelectedCaption = useCaptionsStore( const setSelectedCaption = useCaptionsStore(
(state) => state.setSelectedCaption, (state) => state.setSelectedCaption,
); );
const { isTrue, on, off } = useBoolean();
const downloadAndSetCaption = useCallback( const downloadCaption = useMutation({
(caption: Stream["captions"][0]) => { mutationKey: ["captions", selectedCaption?.id],
parseCaption(caption) mutationFn: parseCaption,
.then((data) => { onSuccess: (data) => {
setSelectedCaption({ ...caption, data }); setSelectedCaption(data);
})
.catch(console.error);
}, },
[setSelectedCaption], });
);
if (!captions?.length) return null; if (!captions?.length) return null;
return ( return (
<View className="max-w-36 flex-1"> <>
<Controls> <Controls>
<Button <MWButton
title="Subtitles" type="secondary"
variant="outline" icon={
onPress={on}
iconLeft={
<MaterialCommunityIcons <MaterialCommunityIcons
name="subtitles" name="subtitles"
size={24} size={24}
color={defaultTheme.extend.colors.buttons.purple} color={theme.buttonSecondaryText.val}
/> />
} }
/> onPress={() => setOpen(true)}
>
Subtitles
</MWButton>
</Controls> </Controls>
<Settings.Sheet
<Modal forceRemoveScrollEnabled={open}
isVisible={isTrue} open={open}
onBackdropPress={off} onOpenChange={setOpen}
supportedOrientations={["portrait", "landscape"]}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
> >
<ScrollView className="flex-1 bg-gray-900"> <Settings.SheetOverlay />
<Text className="text-center font-bold">Select subtitle</Text> <Settings.SheetHandle />
{captions?.map((caption) => ( <Settings.SheetFrame>
<Pressable <Settings.Header
className="flex w-full flex-row justify-between p-3" icon={
key={caption.id} <MaterialIcons
onPress={() => { name="close"
downloadAndSetCaption(caption);
off();
}}
>
<Text>{getPrettyLanguageNameFromLocale(caption.language)}</Text>
<MaterialCommunityIcons
name="download"
size={24} size={24}
color={defaultTheme.extend.colors.buttons.purple} color={theme.playerSettingsUnactiveText.val}
onPress={() => setOpen(false)}
/> />
</Pressable> }
))} title="Subtitles"
</ScrollView> rightButton={
</Modal> <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> </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 { BottomControls } from "./BottomControls";
import { Header } from "./Header"; import { Header } from "./Header";
@@ -6,7 +6,12 @@ import { MiddleControls } from "./MiddleControls";
export const ControlsOverlay = ({ isLoading }: { isLoading: boolean }) => { export const ControlsOverlay = ({ isLoading }: { isLoading: boolean }) => {
return ( return (
<View className="flex w-full flex-1 flex-col justify-between"> <View
width="100%"
flex={1}
flexDirection="column"
justifyContent="space-between"
>
<Header /> <Header />
{!isLoading && <MiddleControls />} {!isLoading && <MiddleControls />}
<BottomControls /> <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 { usePlayerStore } from "~/stores/player/store";
import Icon from "../../../assets/images/icon-transparent.png"; import Icon from "../../../assets/images/icon-transparent.png";
import { Text } from "../ui/Text";
import { BackButton } from "./BackButton"; import { BackButton } from "./BackButton";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
@@ -16,11 +15,20 @@ export const Header = () => {
if (!isIdle && meta) { if (!isIdle && meta) {
return ( return (
<View className="z-50 flex h-16 w-full flex-row items-center justify-between px-6 pt-6"> <View
zIndex={50}
flexDirection="row"
alignItems="center"
justifyContent="space-between"
height={64}
paddingHorizontal="$8"
>
<View width={144}>
<Controls> <Controls>
<BackButton className="w-36" /> <BackButton />
</Controls> </Controls>
<Text className="font-bold"> </View>
<Text fontWeight="bold">
{meta.title} ({meta.releaseYear}){" "} {meta.title} ({meta.releaseYear}){" "}
{meta.season !== undefined && meta.episode !== undefined {meta.season !== undefined && meta.episode !== undefined
? mapSeasonAndEpisodeNumberToText( ? mapSeasonAndEpisodeNumberToText(
@@ -29,9 +37,21 @@ export const Header = () => {
) )
: ""} : ""}
</Text> </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"> <View
<Image source={Icon} className="h-6 w-6" /> height={48}
<Text className="font-bold">movie-web</Text> 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> </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 { usePlayerStore } from "~/stores/player/store";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
@@ -15,8 +16,17 @@ export const MiddleControls = () => {
return ( return (
<TouchableWithoutFeedback onPress={handleTouch}> <TouchableWithoutFeedback onPress={handleTouch}>
<View style={styles.container}> <View
<Controls className="mr-24"> position="absolute"
height="100%"
width="100%"
flex={1}
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={82}
>
<Controls>
<SeekButton type="backward" /> <SeekButton type="backward" />
</Controls> </Controls>
<Controls> <Controls>
@@ -29,16 +39,3 @@ export const MiddleControls = () => {
</TouchableWithoutFeedback> </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 { useState } from "react";
import Modal from "react-native-modal";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useTheme } from "tamagui";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed"; import { usePlaybackSpeed } from "~/hooks/player/usePlaybackSpeed";
import { useBoolean } from "~/hooks/useBoolean"; import { MWButton } from "../ui/Button";
import { Button } from "../ui/Button";
import { Text } from "../ui/Text";
import { Controls } from "./Controls"; import { Controls } from "./Controls";
import { Settings } from "./settings/Sheet";
export const PlaybackSpeedSelector = () => { export const PlaybackSpeedSelector = () => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const { currentSpeed, changePlaybackSpeed } = usePlaybackSpeed(); const { currentSpeed, changePlaybackSpeed } = usePlaybackSpeed();
const { isTrue, on, off } = useBoolean();
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
return ( return (
<View className="max-w-36 flex-1"> <>
<Controls> <Controls>
<Button <MWButton
title="Speed" type="secondary"
variant="outline" icon={
onPress={on}
iconLeft={
<MaterialCommunityIcons <MaterialCommunityIcons
name="speedometer" name="speedometer"
size={24} size={24}
color={defaultTheme.extend.colors.buttons.purple} color={theme.buttonSecondaryText.val}
/> />
} }
/> onPress={() => setOpen(true)}
>
Playback
</MWButton>
</Controls> </Controls>
<Modal <Settings.Sheet
isVisible={isTrue} forceRemoveScrollEnabled={open}
onBackdropPress={off} open={open}
supportedOrientations={["portrait", "landscape"]} onOpenChange={setOpen}
style={{
width: "35%",
justifyContent: "center",
alignSelf: "center",
}}
> >
<ScrollView className="flex-1 bg-gray-900"> <Settings.SheetOverlay />
<Text className="text-center font-bold">Select speed</Text> <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) => ( {speeds.map((speed) => (
<Pressable <Settings.Item
className="flex w-full flex-row justify-between p-3"
key={speed} key={speed}
onPress={() => { title={`${speed}x`}
changePlaybackSpeed(speed); iconRight={
off(); speed === currentSpeed && (
}}
>
<Text>{speed}</Text>
{speed === currentSpeed && (
<MaterialCommunityIcons <MaterialCommunityIcons
name="check-circle" name="check-circle"
size={24} size={24}
color={defaultTheme.extend.colors.buttons.purple} color={theme.sheetItemSelected.val}
/>
)
}
onPress={() => {
changePlaybackSpeed(speed)
.then(() => setOpen(false))
.catch((err) => {
console.log("error", err);
});
}}
/> />
)}
</Pressable>
))} ))}
</ScrollView> </Settings.Content>
</Modal> </Settings.SheetFrame>
</View> </Settings.Sheet>
</>
); );
}; };

View File

@@ -21,7 +21,13 @@ export const ProgressBar = () => {
if (status?.isLoaded) { if (status?.isLoaded) {
return ( return (
<TouchableOpacity <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)} onPress={() => setIsIdle(false)}
> >
<VideoSlider onSlidingComplete={updateProgress} /> <VideoSlider onSlidingComplete={updateProgress} />

View File

@@ -1,11 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import React from "react"; import React from "react";
import { StyleSheet, View } from "react-native";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { Text, useTheme, View } from "tamagui";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { Text } from "../ui/Text";
export interface ScrapeItemProps { export interface ScrapeItemProps {
status: "failure" | "pending" | "notfound" | "success" | "waiting"; status: "failure" | "pending" | "notfound" | "success" | "waiting";
@@ -37,41 +33,42 @@ export function StatusCircle({
type: ScrapeItemProps["status"]; type: ScrapeItemProps["status"];
percentage: number; percentage: number;
}) { }) {
const theme = useTheme();
return ( return (
<> <>
{type === "waiting" && ( {type === "waiting" && (
<MaterialCommunityIcons <MaterialCommunityIcons
name="circle-outline" name="circle-outline"
size={40} size={40}
color={defaultTheme.extend.colors.video.scraping.noresult} color={theme.scrapingNoResult.val}
/> />
)} )}
{type === "pending" && ( {type === "pending" && (
<MaterialCommunityIcons <MaterialCommunityIcons
name={mapPercentageToIcon(percentage) as "circle-slice-1"} name={mapPercentageToIcon(percentage) as "circle-slice-1"}
size={40} size={40}
color={defaultTheme.extend.colors.video.scraping.loading} color={theme.scrapingLoading.val}
/> />
)} )}
{type === "failure" && ( {type === "failure" && (
<MaterialCommunityIcons <MaterialCommunityIcons
name="close-circle" name="close-circle"
size={40} size={40}
color={defaultTheme.extend.colors.video.scraping.error} color={theme.scrapingError.val}
/> />
)} )}
{type === "notfound" && ( {type === "notfound" && (
<MaterialIcons <MaterialIcons
name="remove-circle" name="remove-circle"
size={40} size={40}
color={defaultTheme.extend.colors.video.scraping.noresult} color={theme.scrapingNoResult.val}
/> />
)} )}
{type === "success" && ( {type === "success" && (
<MaterialIcons <MaterialIcons
name="check-circle" name="check-circle"
size={40} 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]; const text = statusTextMap[props.status];
return ( return (
<View style={styles.scrapeItemContainer}> <View flex={1} flexDirection="column">
<View style={styles.itemRow}> <View flexDirection="row" alignItems="center" gap={16}>
<StatusCircle type={props.status} percentage={props.percentage ?? 0} /> <StatusCircle type={props.status} percentage={props.percentage ?? 0} />
<Text <Text
style={[ fontSize={18}
styles.itemText, color={props.status === "pending" ? "$scrapingLoading" : "white"}
props.status === "pending"
? styles.textPending
: styles.textSecondary,
]}
> >
{props.name} {props.name}
</Text> </Text>
</View> </View>
<View style={styles.textRow}> <View flexDirection="row" alignItems="center" gap={16}>
<View style={styles.spacer} /> <View width={40} />
<View>{text && <Text style={styles.statusText}>{text}</Text>}</View> <View>{text && <Text fontSize={18}>{text}</Text>}</View>
</View> </View>
<View style={styles.childrenContainer}>{props.children}</View> <View marginLeft={48}>{props.children}</View>
</View> </View>
); );
} }
export function ScrapeCard(props: ScrapeCardProps) { export function ScrapeCard(props: ScrapeCardProps) {
return ( return (
<View style={styles.cardContainer}> <View width={384}>
<View <View
style={[ width="100%"
styles.cardContent, borderRadius={10}
props.hasChildren ? styles.cardBackground : null, paddingVertical={12}
]} paddingHorizontal={24}
backgroundColor={props.hasChildren ? "$scrapingCard" : "transparent"}
> >
<ScrapeItem {...props} /> <ScrapeItem {...props} />
</View> </View>
</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 { useEffect, useRef } from "react";
import { SafeAreaView, View } from "react-native"; import { SafeAreaView } from "react-native";
import { ScrollView } from "react-native-gesture-handler"; import { ScrollView } from "react-native-gesture-handler";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { View } from "tamagui";
import type { HlsBasedStream } from "@movie-web/provider-utils"; import type { HlsBasedStream } from "@movie-web/provider-utils";
import { extractTracksFromHLS } 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 { useMeta } from "~/hooks/player/useMeta";
import { useScrape } from "~/hooks/player/useSourceScrape"; import { useScrape } from "~/hooks/player/useSourceScrape";
import { constructFullUrl } from "~/lib/url"; import { constructFullUrl } from "~/lib/url";
import { cn } from "~/lib/utils";
import { PlayerStatus } from "~/stores/player/slices/interface"; import { PlayerStatus } from "~/stores/player/slices/interface";
import { convertMetaToScrapeMedia } from "~/stores/player/slices/video"; import { convertMetaToScrapeMedia } from "~/stores/player/slices/video";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
@@ -106,17 +106,35 @@ export const ScraperProcess = ({ data }: ScraperProcessProps) => {
useEffect(() => { useEffect(() => {
scrollViewRef.current?.scrollTo({ scrollViewRef.current?.scrollTo({
y: currentProviderIndex * 80, y: currentProviderIndex * 110,
animated: true, animated: true,
}); });
}, [currentProviderIndex]); }, [currentProviderIndex]);
return ( return (
<SafeAreaView className="flex h-full flex-1 flex-col"> <SafeAreaView
<View className="flex-1 items-center justify-center bg-background-main"> style={{
display: "flex",
height: "100%",
flexDirection: "column",
flex: 1,
}}
>
<View
flex={1}
alignItems="center"
justifyContent="center"
backgroundColor="$screenBackground"
>
<ScrollView <ScrollView
ref={scrollViewRef} ref={scrollViewRef}
contentContainerClassName="items-center flex flex-col py-16" contentContainerStyle={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
paddingVertical: 64,
}}
> >
{sourceOrder.map((order) => { {sourceOrder.map((order) => {
const source = sources[order.id]; const source = sources[order.id];
@@ -138,9 +156,9 @@ export const ScraperProcess = ({ data }: ScraperProcessProps) => {
percentage={source.percentage} percentage={source.percentage}
> >
<View <View
className={cn({ marginTop={order.children.length > 0 ? 8 : 0}
"mt-8 space-y-6": order.children.length > 0, flexDirection="column"
})} gap={16}
> >
{order.children.map((embedId) => { {order.children.map((embedId) => {
const embed = sources[embedId]; const embed = sources[embedId];

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { StyleSheet, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
useAnimatedProps, useAnimatedProps,
@@ -8,6 +7,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Circle, Svg } from "react-native-svg"; import { Circle, Svg } from "react-native-svg";
import { AntDesign } from "@expo/vector-icons"; import { AntDesign } from "@expo/vector-icons";
import { View } from "tamagui";
const AnimatedCircle = Animated.createAnimatedComponent(Circle); const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -50,7 +50,7 @@ export const StatusCircle = ({
}; };
return ( return (
<View style={styles.container}> <View justifyContent="center" alignItems="center" position="relative">
<Svg height="60" width="60" viewBox="0 0 60 60"> <Svg height="60" width="60" viewBox="0 0 60 60">
{type === "loading" && ( {type === "loading" && (
<AnimatedCircle <AnimatedCircle
@@ -70,11 +70,3 @@ export const StatusCircle = ({
</View> </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 type { AVPlaybackSource } from "expo-av";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { Dimensions, Platform } from "react-native";
ActivityIndicator,
Dimensions,
Platform,
StyleSheet,
View,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS, useSharedValue } from "react-native-reanimated"; import { runOnJS, useSharedValue } from "react-native-reanimated";
import { ResizeMode, Video } from "expo-av"; import { ResizeMode, Video } from "expo-av";
@@ -14,6 +8,7 @@ import * as Haptics from "expo-haptics";
import * as NavigationBar from "expo-navigation-bar"; import * as NavigationBar from "expo-navigation-bar";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import * as StatusBar from "expo-status-bar"; import * as StatusBar from "expo-status-bar";
import { Spinner, Text, View } from "tamagui";
import { findHighestQuality } from "@movie-web/provider-utils"; import { findHighestQuality } from "@movie-web/provider-utils";
@@ -24,7 +19,6 @@ import { usePlayer } from "~/hooks/player/usePlayer";
import { useVolume } from "~/hooks/player/useVolume"; import { useVolume } from "~/hooks/player/useVolume";
import { useAudioTrackStore } from "~/stores/audio"; import { useAudioTrackStore } from "~/stores/audio";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
import { Text } from "../ui/Text";
import { CaptionRenderer } from "./CaptionRenderer"; import { CaptionRenderer } from "./CaptionRenderer";
import { ControlsOverlay } from "./ControlsOverlay"; import { ControlsOverlay } from "./ControlsOverlay";
import { isPointInSliderVicinity } from "./VideoSlider"; import { isPointInSliderVicinity } from "./VideoSlider";
@@ -231,7 +225,13 @@ export const VideoPlayer = () => {
return ( return (
<GestureDetector gesture={composedGesture}> <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 <Video
ref={setVideoRef} ref={setVideoRef}
source={videoSrc} source={videoSrc}
@@ -243,8 +243,12 @@ export const VideoPlayer = () => {
onReadyForDisplay={onReadyForDisplay} onReadyForDisplay={onReadyForDisplay}
onPlaybackStatusUpdate={setStatus} onPlaybackStatusUpdate={setStatus}
style={[ style={[
styles.video,
{ {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
...(!isIdle && { ...(!isIdle && {
opacity: 0.7, opacity: 0.7,
}), }),
@@ -252,24 +256,47 @@ export const VideoPlayer = () => {
]} ]}
onTouchStart={() => setIsIdle(!isIdle)} 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 && ( {isLoading && (
<ActivityIndicator <Spinner
size="large" size="large"
color="#0000ff" color="$loadingIndicator"
className="absolute" style={{
position: "absolute",
}}
/> />
)} )}
<ControlsOverlay isLoading={isLoading} /> <ControlsOverlay isLoading={isLoading} />
</View> </View>
{showVolumeOverlay && ( {showVolumeOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50"> <View
<Text className="font-bold">Volume: {debouncedVolume}</Text> position="absolute"
bottom={48}
alignSelf="center"
borderRadius={999}
backgroundColor="black"
padding={12}
opacity={0.5}
>
<Text fontWeight="bold">Volume: {debouncedVolume}</Text>
</View> </View>
)} )}
{showBrightnessOverlay && ( {showBrightnessOverlay && (
<View className="absolute bottom-12 self-center rounded-xl bg-black p-3 opacity-50"> <View
<Text className="font-bold">Brightness: {debouncedBrightness}</Text> position="absolute"
bottom={48}
alignSelf="center"
borderRadius={999}
backgroundColor="black"
padding={12}
opacity={0.5}
>
<Text fontWeight="bold">Brightness: {debouncedBrightness}</Text>
</View> </View>
)} )}
<CaptionRenderer /> <CaptionRenderer />
@@ -277,13 +304,3 @@ export const VideoPlayer = () => {
</GestureDetector> </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, TapGestureHandlerEventPayload,
} from "react-native-gesture-handler"; } from "react-native-gesture-handler";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { Dimensions, StyleSheet, View } from "react-native"; import { Dimensions } from "react-native";
import { import {
PanGestureHandler, PanGestureHandler,
State, State,
@@ -16,8 +16,7 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useTheme, View } from "tamagui";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import { usePlayerStore } from "~/stores/player/store"; import { usePlayerStore } from "~/stores/player/store";
@@ -47,6 +46,7 @@ export const isPointInSliderVicinity = (x: number, y: number) => {
}; };
const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => { const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
const theme = useTheme();
const tapRef = useRef<TapGestureHandler>(null); const tapRef = useRef<TapGestureHandler>(null);
const panRef = useRef<PanGestureHandler>(null); const panRef = useRef<PanGestureHandler>(null);
const status = usePlayerStore((state) => state.status); const status = usePlayerStore((state) => state.status);
@@ -143,13 +143,13 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
]} ]}
> >
<View <View
className="justify-center"
style={[ style={[
{ {
height: trackSize_, height: trackSize_,
borderRadius: trackSize_, borderRadius: trackSize_,
backgroundColor: defaultTheme.extend.colors.video.context.slider, backgroundColor: theme.videoSlider.val,
width, width,
justifyContent: "center",
}, },
]} ]}
> >
@@ -158,8 +158,7 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
{ {
position: "absolute", position: "absolute",
height: trackSize_, height: trackSize_,
backgroundColor: backgroundColor: theme.videoSliderFilled.val,
defaultTheme.extend.colors.video.context.sliderFilled,
borderRadius: trackSize_ / 2, borderRadius: trackSize_ / 2,
}, },
progressStyle, progressStyle,
@@ -172,13 +171,13 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
> >
<Animated.View <Animated.View
style={[ style={[
styles.knob,
{ {
justifyContent: "center",
alignItems: "center",
height: knobSize_, height: knobSize_,
width: knobSize_, width: knobSize_,
borderRadius: knobSize_ / 2, borderRadius: knobSize_ / 2,
backgroundColor: backgroundColor: theme.videoSliderFilled.val,
defaultTheme.extend.colors.video.context.sliderFilled,
}, },
scrollTranslationStyle, scrollTranslationStyle,
]} ]}
@@ -190,11 +189,4 @@ const VideoSlider = ({ onSlidingComplete }: VideoSliderProps) => {
); );
}; };
const styles = StyleSheet.create({
knob: {
justifyContent: "center",
alignItems: "center",
},
});
export default VideoSlider; 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"; /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { ReactNode } from "react"; import type { ButtonProps } from "tamagui";
import type { PressableProps } from "react-native"; import React from "react";
import { Pressable } from "react-native"; import { Button, styled } from "tamagui";
import { cva } from "class-variance-authority";
import { cn } from "~/lib/utils"; const PrimaryButton = styled(Button, {
import { Text } from "./Text"; backgroundColor: "$buttonPrimaryBackground",
color: "$buttonPrimaryText",
fontWeight: "bold",
});
const buttonVariants = cva( const SecondaryButton = styled(Button, {
"flex flex-row items-center justify-center gap-4 rounded-md disabled:opacity-50", backgroundColor: "$buttonSecondaryBackground",
{ color: "$buttonSecondaryText",
variants: { fontWeight: "bold",
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",
},
},
);
export interface ButtonProps const PurpleButton = styled(Button, {
extends PressableProps, backgroundColor: "$buttonPurpleBackground",
VariantProps<typeof buttonVariants> { color: "white",
iconLeft?: ReactNode; fontWeight: "bold",
iconRight?: ReactNode; });
title: string;
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) => {
export function Button({ const { type, ...rest } = props;
onPress, switch (type) {
variant, case "primary":
size, return <PrimaryButton {...rest} ref={ref as any} />;
className, case "secondary":
iconLeft, return <SecondaryButton {...rest} ref={ref as any} />;
iconRight, case "purple":
title, return <PurpleButton {...rest} ref={ref as any} />;
}: ButtonProps) { case "cancel":
return ( return <CancelButton {...rest} ref={ref as any} />;
<Pressable default:
onPress={onPress} return <Button {...rest} ref={ref as any} />;
className={cn(buttonVariants({ variant, size, className }))}
>
{iconLeft}
<Text className="font-bold">{title}</Text>
{iconRight}
</Pressable>
);
} }
});
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 { useContext, useEffect, useRef, useState } from "react";
import { TextInput, View } from "react-native"; import { Keyboard } from "react-native";
import { FontAwesome5 } from "@expo/vector-icons"; import { FontAwesome5 } from "@expo/vector-icons";
import { Input, styled, useTheme, View } from "tamagui";
import { defaultTheme } from "@movie-web/tailwind-config/themes";
import SearchTabContext from "./SearchTabContext"; import SearchTabContext from "./SearchTabContext";
const SearchInput = styled(Input, {
backgroundColor: "$searchBackground",
borderColor: "$colorTransparent",
placeholderTextColor: "$searchPlaceholder",
outlineStyle: "none",
focusStyle: {
borderColor: "$colorTransparent",
backgroundColor: "$searchFocused",
},
});
export function SearchBar({ export function SearchBar({
onSearchChange, onSearchChange,
}: { }: {
onSearchChange: (text: string) => void; onSearchChange: (text: string) => void;
}) { }) {
const theme = useTheme();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const inputRef = useRef<TextInput>(null); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<Input>(null);
const { focusSearchInputRef } = useContext(SearchTabContext); const { focusSearchInputRef } = useContext(SearchTabContext);
@@ -22,27 +34,49 @@ export function SearchBar({
}; };
}, [focusSearchInputRef]); }, [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) => { const handleChange = (text: string) => {
setKeyword(text); setKeyword(text);
onSearchChange(text); onSearchChange(text);
}; };
return ( 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
<View className="ml-1 w-12 items-center justify-center"> marginBottom={12}
<FontAwesome5 flexDirection="row"
name="search" alignItems="center"
size={18} borderRadius={999}
color={defaultTheme.extend.colors.search.icon} 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> </View>
<TextInput <SearchInput
value={keyword} value={keyword}
onChangeText={handleChange} onChangeText={handleChange}
ref={inputRef} ref={inputRef}
placeholder="What are you looking for?" placeholder="What are you looking for?"
placeholderTextColor={defaultTheme.extend.colors.search.placeholder} width="80%"
className="w-full rounded-3xl py-3 pr-5 text-white"
/> />
</View> </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 videoRef = usePlayerStore((state) => state.videoRef);
const changePlaybackSpeed = useCallback( const changePlaybackSpeed = useCallback(
(newValue: number) => { async (newValue: number) => {
if (videoRef) { if (videoRef) {
void videoRef.setRateAsync(newValue, true); await videoRef.setRateAsync(newValue, true);
} }
}, },
[videoRef], [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 setCurrentStream = usePlayerStore((state) => state.setCurrentStream);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -208,8 +208,8 @@ export const useEmbedScrape = (closeModal?: () => void) => {
url, url,
embedId, embedId,
}); });
if (!result) throw new Error("no result");
if (result?.stream) { if (result?.stream) {
closeModal?.();
setCurrentStream(result.stream[0]!); setCurrentStream(result.stream[0]!);
return result.stream; return result.stream;
} }
@@ -224,10 +224,7 @@ export const useEmbedScrape = (closeModal?: () => void) => {
return mutate; return mutate;
}; };
export const useSourceScrape = ( export const useSourceScrape = (sourceId: string | null) => {
sourceId: string | null,
closeModal: () => void,
) => {
const meta = usePlayerStore((state) => state.meta); const meta = usePlayerStore((state) => state.meta);
const setCurrentStream = usePlayerStore((state) => state.setCurrentStream); const setCurrentStream = usePlayerStore((state) => state.setCurrentStream);
const setSourceId = usePlayerStore((state) => state.setSourceId); const setSourceId = usePlayerStore((state) => state.setSourceId);
@@ -235,6 +232,7 @@ export const useSourceScrape = (
const query = useQuery({ const query = useQuery({
queryKey: ["sourceScrape", meta, sourceId], queryKey: ["sourceScrape", meta, sourceId],
queryFn: async () => { queryFn: async () => {
console.log("useSourceScrape", meta, sourceId);
if (!meta || !sourceId) return; if (!meta || !sourceId) return;
const scrapeMedia = convertMetaToScrapeMedia(meta); const scrapeMedia = convertMetaToScrapeMedia(meta);
const result = await getVideoStreamFromSource({ const result = await getVideoStreamFromSource({
@@ -242,13 +240,13 @@ export const useSourceScrape = (
media: scrapeMedia, media: scrapeMedia,
events: { events: {
update(evt) { update(evt) {
console.log(evt); console.log("update useSourceScrape", evt);
}, },
}, },
}); });
console.log("useSourceScrape result", result);
if (result?.stream) { if (result?.stream) {
closeModal();
setCurrentStream(result.stream[0]!); setCurrentStream(result.stream[0]!);
setSourceId(sourceId); setSourceId(sourceId);
return []; return [];
@@ -256,7 +254,6 @@ export const useSourceScrape = (
if (result?.embeds.length === 1) { if (result?.embeds.length === 1) {
const embedResult = await getVideoStreamFromEmbed(result.embeds[0]!); const embedResult = await getVideoStreamFromEmbed(result.embeds[0]!);
if (embedResult?.stream) { if (embedResult?.stream) {
closeModal();
setCurrentStream(embedResult.stream[0]!); setCurrentStream(embedResult.stream[0]!);
setSourceId(sourceId); setSourceId(sourceId);
return []; 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"; import type { Stream } from "@movie-web/provider-utils";
type CaptionWithData = Stream["captions"][0] & { export type CaptionWithData = Stream["captions"][0] & {
data: ContentCaption[]; 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" />

View File

@@ -1,28 +0,0 @@
import type { Config } from "tailwindcss";
// @ts-expect-error - no types
import nativewind from "nativewind/preset";
import baseConfig from "@movie-web/tailwind-config/native";
export default {
content: ["./src/**/*.{ts,tsx}"],
presets: [
nativewind,
baseConfig,
{
theme: {
extend: {
fontFamily: {
sans: ["OpenSansRegular"],
thin: ["OpenSansLight"],
normal: ["OpenSansRegular"],
medium: ["OpenSansMedium"],
semibold: ["OpenSansSemiBold"],
bold: ["OpenSansBold"],
extrabold: ["OpenSansExtra"],
},
},
},
},
],
} satisfies Config;

876
apps/expo/tamagui-web.css Normal file

File diff suppressed because one or more lines are too long

139
apps/expo/tamagui.config.ts Normal file
View File

@@ -0,0 +1,139 @@
import { createAnimations } from "@tamagui/animations-moti";
import { config } from "@tamagui/config/v3";
import { createTamagui } from "tamagui";
import {
blueTokens,
grayTokens,
mainTokens,
redTokens,
tealTokens,
} from "@movie-web/colors";
type Tokens =
| typeof mainTokens
| typeof blueTokens
| typeof grayTokens
| typeof redTokens
| typeof tealTokens;
const createThemeConfig = (tokens: Tokens) => ({
screenBackground: tokens.shade.c900,
searchIcon: tokens.shade.c100,
searchBackground: tokens.shade.c600,
searchHoverBackground: tokens.shade.c600,
searchFocused: tokens.shade.c400,
searchPlaceholder: tokens.shade.c100,
tabBarBackground: tokens.shade.c700,
tabBarIcon: tokens.shade.c300,
tabBarIconFocused: tokens.purple.c200,
scrapingCard: tokens.shade.c700,
scrapingLoading: tokens.purple.c200,
scrapingNoResult: tokens.ash.c100,
scrapingError: tokens.semantic.red.c200,
scrapingSuccess: tokens.semantic.green.c200,
playerSettingsBackground: tokens.ash.c900,
playerSettingsUnactiveText: tokens.semantic.silver.c400,
playerSettingsActiveText: tokens.shade.c100,
subtitleSelectorBackground: tokens.ash.c500,
pillBackground: tokens.shade.c300,
pillHighlight: tokens.blue.c200,
pillActiveBackground: tokens.shade.c300,
sheetBackground: tokens.shade.c800,
sheetItemBackground: tokens.shade.c600,
sheetIcon: tokens.shade.c300,
sheetText: tokens.shade.c100,
sheetHandle: tokens.shade.c300,
sheetItemSelected: tokens.purple.c200,
videoSlider: tokens.ash.c50,
videoSliderFilled: tokens.purple.c200,
progressBackground: tokens.shade.c600,
progressFilled: tokens.purple.c200,
separatorBackground: tokens.ash.c600,
loadingIndicator: tokens.purple.c200,
buttonSecondaryBackground: tokens.ash.c700,
buttonSecondaryText: tokens.semantic.silver.c300,
buttonSecondaryBackgroundHover: tokens.ash.c700,
buttonPrimaryBackground: tokens.white,
buttonPrimaryText: tokens.black,
buttonPrimaryBackgroundHover: tokens.semantic.silver.c100,
buttonPurpleBackground: tokens.purple.c500,
buttonPurpleBackgroundHover: tokens.purple.c400,
buttonCancelBackground: tokens.ash.c500,
buttonCancelBackgroundHover: tokens.ash.c300,
});
export const tamaguiConfig = createTamagui({
...config,
tokens: config.tokens,
themes: {
main: {
...config.themes.dark,
...createThemeConfig(mainTokens),
},
blue: {
...config.themes.dark,
...createThemeConfig(blueTokens),
},
gray: {
...config.themes.dark,
...createThemeConfig(grayTokens),
},
red: {
...config.themes.dark,
...createThemeConfig(redTokens),
},
teal: {
...config.themes.dark,
...createThemeConfig(tealTokens),
},
},
animations: createAnimations({
fast: {
type: "spring",
damping: 20,
mass: 1.2,
stiffness: 250,
},
bounce: {
type: "spring",
stiffness: 200,
damping: 10,
},
quicker: {
type: "spring",
stiffness: 300,
damping: 20,
},
static: {
type: "decay",
deceleration: 0.999,
},
lazy: {
type: "spring",
stiffness: 100,
damping: 20,
},
}),
});
export default tamaguiConfig;
export type Conf = typeof tamaguiConfig;
declare module "tamagui" {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface TamaguiCustomConfig extends Conf {}
}

View File

@@ -9,7 +9,6 @@
}, },
"jsx": "react-native", "jsx": "react-native",
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"types": ["nativewind/types"],
}, },
"include": ["src", "*.ts", "*.js", ".expo/types/**/*.ts", "expo-env.d.ts"], "include": ["src", "*.ts", "*.js", ".expo/types/**/*.ts", "expo-env.d.ts"],
"exclude": ["node_modules"], "exclude": ["node_modules"],

3293
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{ {
"name": "@movie-web/tailwind-config", "name": "@movie-web/colors",
"version": "0.1.0",
"private": true, "private": true,
"version": "0.1.0",
"type": "module", "type": "module",
"main": "./src/index.ts",
"exports": { "exports": {
"./native": "./native.ts", ".": "./src/index.ts"
"./themes": "./themes/index.ts"
}, },
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -14,13 +14,6 @@
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": {
"autoprefixer": "^10.4.17",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-themer": "^4.0.0"
},
"devDependencies": { "devDependencies": {
"@movie-web/eslint-config": "workspace:^0.2.0", "@movie-web/eslint-config": "workspace:^0.2.0",
"@movie-web/prettier-config": "workspace:^0.1.0", "@movie-web/prettier-config": "workspace:^0.1.0",
@@ -30,7 +23,6 @@
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"eslintConfig": { "eslintConfig": {
"root": true,
"extends": [ "extends": [
"@movie-web/eslint-config/base" "@movie-web/eslint-config/base"
] ]

View File

@@ -0,0 +1,93 @@
import { tokens as blueTokens } from "./list/blue";
import { tokens as grayTokens } from "./list/gray";
import { tokens as redTokens } from "./list/red";
import { tokens as tealTokens } from "./list/teal";
export { blueTokens, grayTokens, redTokens, tealTokens };
export const name = "colors";
export const mainTokens = {
black: "#000000",
white: "#FFFFFF",
semantic: {
red: {
c100: "#F46E6E",
c200: "#E44F4F",
c300: "#D74747",
c400: "#B43434",
},
green: {
c100: "#60D26A",
c200: "#40B44B",
c300: "#31A33C",
c400: "#237A2B",
},
silver: {
c100: "#DEDEDE",
c200: "#B6CAD7",
c300: "#8EA3B0",
c400: "#617A8A",
},
yellow: {
c100: "#FFF599",
c200: "#FCEC61",
c300: "#D8C947",
c400: "#AFA349",
},
rose: {
c100: "#DB3D61",
c200: "#8A293B",
c300: "#812435",
c400: "#701B2B",
},
},
blue: {
c50: "#ADADF5",
c100: "#7979CC",
c200: "#5D5DAE",
c300: "#3B3B8C",
c400: "#2A2A71",
c500: "#1F1F50",
c600: "#1B1B41",
c700: "#171736",
c800: "#101020",
c900: "#0B0B13",
},
purple: {
c50: "#D5AAFF",
c100: "#C082FF",
c200: "#A359EC",
c300: "#8D44D6",
c400: "#7831BF",
c500: "#572887",
c600: "#411F64",
c700: "#31184A",
c800: "#221134",
c900: "#160B22",
},
ash: {
c50: "#7F8D9B",
c100: "#5B6B7B",
c200: "#445464",
c300: "#2B3D4E",
c400: "#203242",
c500: "#1C2C3C",
c600: "#172532",
c700: "#131E29",
c800: "#101820",
c900: "#0C1216",
},
shade: {
c50: "#676790",
c100: "#52527A",
c200: "#3F3F60",
c300: "#32324F",
c400: "#272741",
c500: "#1E1E32",
c600: "#171728",
c700: "#131322",
c800: "#0F0F1B",
c900: "#0A0A12",
},
};

View File

@@ -0,0 +1,84 @@
export const tokens = {
black: "#000000",
white: "#FFFFFF",
semantic: {
red: {
c100: "#F46E6E",
c200: "#E44F4F",
c300: "#D74747",
c400: "#B43434",
},
green: {
c100: "#60D26A",
c200: "#40B44B",
c300: "#31A33C",
c400: "#237A2B",
},
silver: {
c100: "#DEDEDE",
c200: "#B6CAD7",
c300: "#8EA3B0",
c400: "#617A8A",
},
yellow: {
c100: "#FFF599",
c200: "#FCEC61",
c300: "#D8C947",
c400: "#AFA349",
},
rose: {
c100: "#DB3D61",
c200: "#8A293B",
c300: "#812435",
c400: "#701B2B",
},
},
purple: {
c50: "#aac5ff",
c100: "#82a9ff",
c200: "#4681ff",
c300: "#1a63ff",
c400: "#054eec",
c500: "#083aa7",
c600: "#072c7c",
c700: "#06215d",
c800: "#041741",
c900: "#03102a",
},
shade: {
c50: "#756790",
c100: "#60527a",
c200: "#4a3f60",
c300: "#3c324f",
c400: "#302741",
c500: "#251e32",
c600: "#1d1728",
c700: "#181322",
c800: "#130f1b",
c900: "#0d0a12",
},
ash: {
c50: "#7f859b",
c100: "#5b627b",
c200: "#444b64",
c300: "#2b344e",
c400: "#202842",
c500: "#1c243c",
c600: "#171d32",
c700: "#131829",
c800: "#101420",
c900: "#0c0f16",
},
blue: {
c50: "#adb4f5",
c100: "#7981cc",
c200: "#5d65ae",
c300: "#3b438c",
c400: "#2a3171",
c500: "#1f2450",
c600: "#1b1f41",
c700: "#171b36",
c800: "#101120",
c900: "#0b0c13",
},
};

View File

@@ -0,0 +1,84 @@
export const tokens = {
black: "#000000",
white: "#FFFFFF",
semantic: {
red: {
c100: "#F46E6E",
c200: "#E44F4F",
c300: "#D74747",
c400: "#B43434",
},
green: {
c100: "#60D26A",
c200: "#40B44B",
c300: "#31A33C",
c400: "#237A2B",
},
silver: {
c100: "#DEDEDE",
c200: "#B6CAD7",
c300: "#8EA3B0",
c400: "#617A8A",
},
yellow: {
c100: "#FFF599",
c200: "#FCEC61",
c300: "#D8C947",
c400: "#AFA349",
},
rose: {
c100: "#DB3D61",
c200: "#8A293B",
c300: "#812435",
c400: "#701B2B",
},
},
purple: {
c50: "#aaafff",
c100: "#8288fe",
c200: "#5a62eb",
c300: "#454cd4",
c400: "#333abe",
c500: "#292d86",
c600: "#1f2363",
c700: "#191b4a",
c800: "#111334",
c900: "#0b0d22",
},
shade: {
c50: "#7c7c7c",
c100: "#666666",
c200: "#4f4f4f",
c300: "#404040",
c400: "#343434",
c500: "#282828",
c600: "#202020",
c700: "#1a1a1a",
c800: "#151515",
c900: "#0e0e0e",
},
ash: {
c50: "#8d8d8d",
c100: "#6b6b6b",
c200: "#545454",
c300: "#3c3c3c",
c400: "#313131",
c500: "#2c2c2c",
c600: "#252525",
c700: "#1e1e1e",
c800: "#181818",
c900: "#111111",
},
blue: {
c50: "#ccccd6",
c100: "#a2a2a2",
c200: "#868686",
c300: "#646464",
c400: "#4e4e4e",
c500: "#383838",
c600: "#2e2e2e",
c700: "#272727",
c800: "#181818",
c900: "#0f0f0f",
},
};

View File

@@ -0,0 +1,84 @@
export const tokens = {
black: "#000000",
white: "#FFFFFF",
semantic: {
red: {
c100: "#F46E6E",
c200: "#E44F4F",
c300: "#D74747",
c400: "#B43434",
},
green: {
c100: "#60D26A",
c200: "#40B44B",
c300: "#31A33C",
c400: "#237A2B",
},
silver: {
c100: "#DEDEDE",
c200: "#B6CAD7",
c300: "#8EA3B0",
c400: "#617A8A",
},
yellow: {
c100: "#FFF599",
c200: "#FCEC61",
c300: "#D8C947",
c400: "#AFA349",
},
rose: {
c100: "#DB3D61",
c200: "#8A293B",
c300: "#812435",
c400: "#701B2B",
},
},
purple: {
c50: "#feabac",
c100: "#fe8385",
c200: "#ea5b5e",
c300: "#d34648",
c400: "#bd3436",
c500: "#852a2b",
c600: "#632021",
c700: "#49191a",
c800: "#331112",
c900: "#220c0c",
},
shade: {
c50: "#9c605c",
c100: "#834d49",
c200: "#673b38",
c300: "#542f2c",
c400: "#452422",
c500: "#361c1a",
c600: "#2b1614",
c700: "#241210",
c800: "#1c0e0d",
c900: "#130909",
},
ash: {
c50: "#ac6e6f",
c100: "#8b4b4c",
c200: "#703739",
c300: "#572225",
c400: "#49191a",
c500: "#421617",
c600: "#371212",
c700: "#2e0e0f",
c800: "#230c0d",
c900: "#19090b",
},
blue: {
c50: "#f5adb4",
c100: "#cc7981",
c200: "#ae5d65",
c300: "#8c3b43",
c400: "#712a31",
c500: "#501f24",
c600: "#411b1f",
c700: "#36171b",
c800: "#201011",
c900: "#130b0c",
},
};

View File

@@ -0,0 +1,84 @@
export const tokens = {
black: "#000000",
white: "#FFFFFF",
semantic: {
red: {
c100: "#F46E6E",
c200: "#E44F4F",
c300: "#D74747",
c400: "#B43434",
},
green: {
c100: "#60D26A",
c200: "#40B44B",
c300: "#31A33C",
c400: "#237A2B",
},
silver: {
c100: "#DEDEDE",
c200: "#B6CAD7",
c300: "#8EA3B0",
c400: "#617A8A",
},
yellow: {
c100: "#FFF599",
c200: "#FCEC61",
c300: "#D8C947",
c400: "#AFA349",
},
rose: {
c100: "#DB3D61",
c200: "#8A293B",
c300: "#812435",
c400: "#701B2B",
},
},
purple: {
c50: "#aad7ff",
c100: "#82c4ff",
c200: "#59a8ec",
c300: "#4491d6",
c400: "#317dbf",
c500: "#285b87",
c600: "#1f4464",
c700: "#18334a",
c800: "#112434",
c900: "#0b1822",
},
shade: {
c50: "#677c90",
c100: "#52667a",
c200: "#3f4f60",
c300: "#32404f",
c400: "#273441",
c500: "#1e2832",
c600: "#172028",
c700: "#131a22",
c800: "#0f151b",
c900: "#0a0e12",
},
ash: {
c50: "#7f9b9b",
c100: "#5b7b7b",
c200: "#446463",
c300: "#2b4e4d",
c400: "#204241",
c500: "#1c3c3b",
c600: "#173232",
c700: "#132929",
c800: "#102020",
c900: "#0c1615",
},
blue: {
c50: "#adf5d6",
c100: "#79cca8",
c200: "#5dae8b",
c300: "#3b8c69",
c400: "#2a7152",
c500: "#1f503b",
c600: "#1b4130",
c700: "#173629",
c800: "#102019",
c900: "#0b1310",
},
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@movie-web/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -30,6 +30,7 @@ const config = {
], ],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"], "import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off",
}, },
ignorePatterns: [ ignorePatterns: [
"**/*.config.js", "**/*.config.js",

View File

@@ -1,19 +1,11 @@
import { fileURLToPath } from "url";
/** @typedef {import("prettier").Config} PrettierConfig */ /** @typedef {import("prettier").Config} PrettierConfig */
/** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */
/** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */
/** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */ /** @type { PrettierConfig | SortImportsConfig } */
const config = { const config = {
plugins: [ plugins: [
"@ianvs/prettier-plugin-sort-imports", "@ianvs/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss",
], ],
tailwindConfig: fileURLToPath(
new URL("../../tooling/tailwind/native.ts", import.meta.url),
),
tailwindFunctions: ["cn", "cva"],
importOrder: [ importOrder: [
"<TYPES>", "<TYPES>",
"^(react/(.*)$)|^(react$)|^(react-native(.*)$)", "^(react/(.*)$)|^(react$)|^(react-native(.*)$)",

View File

@@ -1,22 +0,0 @@
import type { Config } from "tailwindcss";
import themer from "tailwindcss-themer";
import { allThemes, defaultTheme, safeThemeList } from "./themes";
export default {
content: ["src/**/*.{ts,tsx}"],
safelist: safeThemeList,
plugins: [
themer({
defaultTheme,
themes: [
{
name: "default",
selectors: [".theme-default"],
...defaultTheme,
},
...allThemes,
],
}),
],
} satisfies Config;

View File

@@ -1,6 +0,0 @@
import blue from "./list/blue";
import gray from "./list/gray";
import red from "./list/red";
import teal from "./list/teal";
export const allThemes = [teal, blue, gray, red];

View File

@@ -1,352 +0,0 @@
const tokens = {
black: "#000000",
white: "#FFFFFF",
semantic: {
red: {
c100: "#F46E6E",
c200: "#E44F4F",
c300: "#D74747",
c400: "#B43434",
},
green: {
c100: "#60D26A",
c200: "#40B44B",
c300: "#31A33C",
c400: "#237A2B",
},
silver: {
c100: "#DEDEDE",
c200: "#B6CAD7",
c300: "#8EA3B0",
c400: "#617A8A",
},
yellow: {
c100: "#FFF599",
c200: "#FCEC61",
c300: "#D8C947",
c400: "#AFA349",
},
rose: {
c100: "#DB3D61",
c200: "#8A293B",
c300: "#812435",
c400: "#701B2B",
},
},
blue: {
c50: "#ADADF5",
c100: "#7979CC",
c200: "#5D5DAE",
c300: "#3B3B8C",
c400: "#2A2A71",
c500: "#1F1F50",
c600: "#1B1B41",
c700: "#171736",
c800: "#101020",
c900: "#0B0B13",
},
purple: {
c50: "#D5AAFF",
c100: "#C082FF",
c200: "#A359EC",
c300: "#8D44D6",
c400: "#7831BF",
c500: "#572887",
c600: "#411F64",
c700: "#31184A",
c800: "#221134",
c900: "#160B22",
},
ash: {
c50: "#7F8D9B",
c100: "#5B6B7B",
c200: "#445464",
c300: "#2B3D4E",
c400: "#203242",
c500: "#1C2C3C",
c600: "#172532",
c700: "#131E29",
c800: "#101820",
c900: "#0C1216",
},
shade: {
c50: "#676790",
c100: "#52527A",
c200: "#3F3F60",
c300: "#32324F",
c400: "#272741",
c500: "#1E1E32",
c600: "#171728",
c700: "#131322",
c800: "#0F0F1B",
c900: "#0A0A12",
},
};
export const defaultTheme = {
extend: {
colors: {
themePreview: {
primary: tokens.blue.c200,
secondary: tokens.shade.c50,
ghost: tokens.white,
},
// Branding
pill: {
background: tokens.shade.c300,
backgroundHover: tokens.shade.c200,
highlight: tokens.blue.c200,
activeBackground: tokens.shade.c300,
},
// meta data for the theme itself
global: {
accentA: tokens.blue.c200,
accentB: tokens.blue.c300,
},
// light bar
lightBar: {
light: tokens.blue.c400,
},
// Buttons
buttons: {
toggle: tokens.purple.c300,
toggleDisabled: tokens.ash.c500,
danger: tokens.semantic.rose.c300,
dangerHover: tokens.semantic.rose.c200,
secondary: tokens.ash.c700,
secondaryText: tokens.semantic.silver.c300,
secondaryHover: tokens.ash.c700,
primary: tokens.white,
primaryText: tokens.black,
primaryHover: tokens.semantic.silver.c100,
purple: tokens.purple.c500,
purpleHover: tokens.purple.c400,
cancel: tokens.ash.c500,
cancelHover: tokens.ash.c300,
},
// only used for body colors/textures
background: {
main: tokens.shade.c900,
secondary: tokens.shade.c600,
secondaryHover: tokens.shade.c400,
accentA: tokens.purple.c500,
accentB: tokens.blue.c500,
},
// Modals
modal: {
background: tokens.shade.c800,
},
// typography
type: {
logo: tokens.purple.c100,
emphasis: tokens.white,
text: tokens.shade.c50,
dimmed: tokens.shade.c50,
divider: tokens.ash.c500,
secondary: tokens.ash.c100,
danger: tokens.semantic.red.c100,
success: tokens.semantic.green.c100,
link: tokens.purple.c100,
linkHover: tokens.purple.c50,
},
// search bar
search: {
background: tokens.shade.c500,
hoverBackground: tokens.shade.c600,
focused: tokens.shade.c400,
placeholder: tokens.shade.c100,
icon: tokens.shade.c100,
text: tokens.white,
},
// media cards
mediaCard: {
hoverBackground: tokens.shade.c600,
hoverAccent: tokens.shade.c50,
hoverShadow: tokens.shade.c900,
shadow: tokens.shade.c700,
barColor: tokens.ash.c200,
barFillColor: tokens.purple.c100,
badge: tokens.shade.c700,
badgeText: tokens.ash.c100,
},
// Large card
largeCard: {
background: tokens.shade.c600,
icon: tokens.purple.c400,
},
// Dropdown
dropdown: {
background: tokens.shade.c600,
altBackground: tokens.shade.c700,
hoverBackground: tokens.shade.c500,
highlight: tokens.semantic.yellow.c400,
highlightHover: tokens.semantic.yellow.c200,
text: tokens.shade.c50,
secondary: tokens.shade.c100,
border: tokens.shade.c400,
contentBackground: tokens.shade.c500,
},
// Passphrase
authentication: {
border: tokens.shade.c300,
inputBg: tokens.shade.c600,
inputBgHover: tokens.shade.c500,
wordBackground: tokens.shade.c500,
copyText: tokens.shade.c100,
copyTextHover: tokens.ash.c50,
errorText: tokens.semantic.rose.c100,
},
// Settings page
settings: {
sidebar: {
activeLink: tokens.shade.c600,
badge: tokens.shade.c900,
type: {
secondary: tokens.shade.c200,
inactive: tokens.shade.c50,
icon: tokens.shade.c50,
iconActivated: tokens.purple.c200,
activated: tokens.purple.c50,
},
},
card: {
border: tokens.shade.c400,
background: tokens.shade.c400,
altBackground: tokens.shade.c400,
},
saveBar: {
background: tokens.shade.c800,
},
},
// Utilities
utils: {
divider: tokens.ash.c300,
},
// Onboarding
onboarding: {
bar: tokens.shade.c400,
barFilled: tokens.purple.c300,
divider: tokens.shade.c200,
card: tokens.shade.c800,
cardHover: tokens.shade.c700,
border: tokens.shade.c600,
good: tokens.purple.c100,
best: tokens.semantic.yellow.c100,
link: tokens.purple.c100,
},
// Error page
errors: {
card: tokens.shade.c800,
border: tokens.ash.c500,
type: {
secondary: tokens.ash.c100,
},
},
// About page
about: {
circle: tokens.ash.c500,
circleText: tokens.ash.c50,
},
// About page
editBadge: {
bg: tokens.ash.c500,
bgHover: tokens.ash.c400,
text: tokens.ash.c50,
},
progress: {
background: tokens.ash.c50,
preloaded: tokens.ash.c50,
filled: tokens.purple.c200,
},
// video player
video: {
buttonBackground: tokens.ash.c200,
autoPlay: {
background: tokens.ash.c700,
hover: tokens.ash.c500,
},
scraping: {
card: tokens.shade.c700,
error: tokens.semantic.red.c200,
success: tokens.semantic.green.c200,
loading: tokens.purple.c200,
noresult: tokens.ash.c100,
},
audio: {
set: tokens.purple.c200,
},
context: {
background: tokens.ash.c900,
light: tokens.shade.c50,
border: tokens.ash.c600,
hoverColor: tokens.ash.c600,
buttonFocus: tokens.ash.c500,
flagBg: tokens.ash.c500,
inputBg: tokens.ash.c600,
buttonOverInputHover: tokens.ash.c500,
inputPlaceholder: tokens.ash.c200,
cardBorder: tokens.ash.c700,
slider: tokens.ash.c50,
sliderFilled: tokens.purple.c200,
error: tokens.semantic.red.c200,
buttons: {
list: tokens.ash.c700,
active: tokens.ash.c900,
},
closeHover: tokens.ash.c800,
type: {
main: tokens.semantic.silver.c400,
secondary: tokens.ash.c200,
accent: tokens.purple.c200,
},
},
},
// tabBar
tabBar: {
background: tokens.shade.c700,
active: tokens.purple.c200,
inactive: tokens.shade.c300,
},
// download
download: {
progress: tokens.ash.c50,
progressFilled: tokens.purple.c200,
},
},
},
};

View File

@@ -1,9 +0,0 @@
import { allThemes } from "./all";
export { defaultTheme } from "./default";
export { allThemes } from "./all";
export const safeThemeList = allThemes
.flatMap((v) => v.selectors)
.filter((v) => v.startsWith("."))
.map((v) => v.slice(1)); // remove dot from selector

View File

@@ -1,259 +0,0 @@
import { createTheme } from "../types";
const tokens = {
purple: {
c50: "#aac5ff",
c100: "#82a9ff",
c200: "#4681ff",
c300: "#1a63ff",
c400: "#054eec",
c500: "#083aa7",
c600: "#072c7c",
c700: "#06215d",
c800: "#041741",
c900: "#03102a",
},
shade: {
c50: "#756790",
c100: "#60527a",
c200: "#4a3f60",
c300: "#3c324f",
c400: "#302741",
c500: "#251e32",
c600: "#1d1728",
c700: "#181322",
c800: "#130f1b",
c900: "#0d0a12",
},
ash: {
c50: "#7f859b",
c100: "#5b627b",
c200: "#444b64",
c300: "#2b344e",
c400: "#202842",
c500: "#1c243c",
c600: "#171d32",
c700: "#131829",
c800: "#101420",
c900: "#0c0f16",
},
blue: {
c50: "#adb4f5",
c100: "#7981cc",
c200: "#5d65ae",
c300: "#3b438c",
c400: "#2a3171",
c500: "#1f2450",
c600: "#1b1f41",
c700: "#171b36",
c800: "#101120",
c900: "#0b0c13",
},
};
export default createTheme({
name: "blue",
extend: {
colors: {
themePreview: {
primary: tokens.blue.c200,
secondary: tokens.shade.c50,
},
pill: {
background: tokens.shade.c300,
backgroundHover: tokens.shade.c200,
highlight: tokens.blue.c200,
activeBackground: tokens.shade.c300,
},
global: {
accentA: tokens.blue.c200,
accentB: tokens.blue.c300,
},
lightBar: {
light: tokens.blue.c400,
},
buttons: {
toggle: tokens.purple.c300,
toggleDisabled: tokens.ash.c500,
secondary: tokens.ash.c700,
secondaryHover: tokens.ash.c700,
purple: tokens.purple.c500,
purpleHover: tokens.purple.c400,
cancel: tokens.ash.c500,
cancelHover: tokens.ash.c300,
},
background: {
main: tokens.shade.c900,
secondary: tokens.shade.c600,
secondaryHover: tokens.shade.c400,
accentA: tokens.purple.c500,
accentB: tokens.blue.c500,
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,
dimmed: tokens.shade.c50,
divider: tokens.ash.c500,
secondary: tokens.ash.c100,
link: tokens.purple.c100,
linkHover: tokens.purple.c50,
},
search: {
background: tokens.shade.c500,
hoverBackground: tokens.shade.c600,
focused: tokens.shade.c400,
placeholder: tokens.shade.c100,
icon: tokens.shade.c100,
},
mediaCard: {
hoverBackground: tokens.shade.c600,
hoverAccent: tokens.shade.c50,
hoverShadow: tokens.shade.c900,
shadow: tokens.shade.c700,
barColor: tokens.ash.c200,
barFillColor: tokens.purple.c100,
badge: tokens.shade.c700,
badgeText: tokens.ash.c100,
},
largeCard: {
background: tokens.shade.c600,
icon: tokens.purple.c400,
},
dropdown: {
background: tokens.shade.c600,
altBackground: tokens.shade.c700,
hoverBackground: tokens.shade.c500,
text: tokens.shade.c50,
secondary: tokens.shade.c100,
border: tokens.shade.c400,
contentBackground: tokens.shade.c500,
},
authentication: {
border: tokens.shade.c300,
inputBg: tokens.shade.c600,
inputBgHover: tokens.shade.c500,
wordBackground: tokens.shade.c500,
copyText: tokens.shade.c100,
copyTextHover: tokens.ash.c50,
},
settings: {
sidebar: {
activeLink: tokens.shade.c600,
badge: tokens.shade.c900,
type: {
secondary: tokens.shade.c200,
inactive: tokens.shade.c50,
icon: tokens.shade.c50,
iconActivated: tokens.purple.c200,
activated: tokens.purple.c50,
},
},
card: {
border: tokens.shade.c400,
background: tokens.shade.c400,
altBackground: tokens.shade.c400,
},
saveBar: {
background: tokens.shade.c800,
},
},
utils: {
divider: tokens.ash.c300,
},
errors: {
card: tokens.shade.c800,
border: tokens.ash.c500,
type: {
secondary: tokens.ash.c100,
},
},
about: {
circle: tokens.ash.c500,
circleText: tokens.ash.c50,
},
editBadge: {
bg: tokens.ash.c500,
bgHover: tokens.ash.c400,
text: tokens.ash.c50,
},
progress: {
background: tokens.ash.c50,
preloaded: tokens.ash.c50,
filled: tokens.purple.c200,
},
video: {
buttonBackground: tokens.ash.c200,
autoPlay: {
background: tokens.ash.c700,
hover: tokens.ash.c500,
},
scraping: {
card: tokens.shade.c700,
loading: tokens.purple.c200,
noresult: tokens.ash.c100,
},
audio: {
set: tokens.purple.c200,
},
context: {
background: tokens.ash.c900,
light: tokens.shade.c50,
border: tokens.ash.c600,
hoverColor: tokens.ash.c600,
buttonFocus: tokens.ash.c500,
flagBg: tokens.ash.c500,
inputBg: tokens.ash.c600,
buttonOverInputHover: tokens.ash.c500,
inputPlaceholder: tokens.ash.c200,
cardBorder: tokens.ash.c700,
slider: tokens.ash.c50,
sliderFilled: tokens.purple.c200,
buttons: {
list: tokens.ash.c700,
active: tokens.ash.c900,
},
closeHover: tokens.ash.c800,
type: {
secondary: tokens.ash.c200,
accent: tokens.purple.c200,
},
},
},
},
},
});

View File

@@ -1,259 +0,0 @@
import { createTheme } from "../types";
const tokens = {
purple: {
c50: "#aaafff",
c100: "#8288fe",
c200: "#5a62eb",
c300: "#454cd4",
c400: "#333abe",
c500: "#292d86",
c600: "#1f2363",
c700: "#191b4a",
c800: "#111334",
c900: "#0b0d22",
},
shade: {
c50: "#7c7c7c",
c100: "#666666",
c200: "#4f4f4f",
c300: "#404040",
c400: "#343434",
c500: "#282828",
c600: "#202020",
c700: "#1a1a1a",
c800: "#151515",
c900: "#0e0e0e",
},
ash: {
c50: "#8d8d8d",
c100: "#6b6b6b",
c200: "#545454",
c300: "#3c3c3c",
c400: "#313131",
c500: "#2c2c2c",
c600: "#252525",
c700: "#1e1e1e",
c800: "#181818",
c900: "#111111",
},
blue: {
c50: "#ccccd6",
c100: "#a2a2a2",
c200: "#868686",
c300: "#646464",
c400: "#4e4e4e",
c500: "#383838",
c600: "#2e2e2e",
c700: "#272727",
c800: "#181818",
c900: "#0f0f0f",
},
};
export default createTheme({
name: "gray",
extend: {
colors: {
themePreview: {
primary: tokens.blue.c200,
secondary: tokens.shade.c50,
},
pill: {
background: tokens.shade.c300,
backgroundHover: tokens.shade.c200,
highlight: tokens.blue.c200,
activeBackground: tokens.shade.c300,
},
global: {
accentA: tokens.blue.c200,
accentB: tokens.blue.c300,
},
lightBar: {
light: tokens.blue.c400,
},
buttons: {
toggle: tokens.purple.c300,
toggleDisabled: tokens.ash.c500,
secondary: tokens.ash.c700,
secondaryHover: tokens.ash.c700,
purple: tokens.purple.c500,
purpleHover: tokens.purple.c400,
cancel: tokens.ash.c500,
cancelHover: tokens.ash.c300,
},
background: {
main: tokens.shade.c900,
secondary: tokens.shade.c600,
secondaryHover: tokens.shade.c400,
accentA: tokens.purple.c500,
accentB: tokens.blue.c500,
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,
dimmed: tokens.shade.c50,
divider: tokens.ash.c500,
secondary: tokens.ash.c100,
link: tokens.purple.c100,
linkHover: tokens.purple.c50,
},
search: {
background: tokens.shade.c500,
hoverBackground: tokens.shade.c600,
focused: tokens.shade.c400,
placeholder: tokens.shade.c100,
icon: tokens.shade.c100,
},
mediaCard: {
hoverBackground: tokens.shade.c600,
hoverAccent: tokens.shade.c50,
hoverShadow: tokens.shade.c900,
shadow: tokens.shade.c700,
barColor: tokens.ash.c200,
barFillColor: tokens.purple.c100,
badge: tokens.shade.c700,
badgeText: tokens.ash.c100,
},
largeCard: {
background: tokens.shade.c600,
icon: tokens.purple.c400,
},
dropdown: {
background: tokens.shade.c600,
altBackground: tokens.shade.c700,
hoverBackground: tokens.shade.c500,
text: tokens.shade.c50,
secondary: tokens.shade.c100,
border: tokens.shade.c400,
contentBackground: tokens.shade.c500,
},
authentication: {
border: tokens.shade.c300,
inputBg: tokens.shade.c600,
inputBgHover: tokens.shade.c500,
wordBackground: tokens.shade.c500,
copyText: tokens.shade.c100,
copyTextHover: tokens.ash.c50,
},
settings: {
sidebar: {
activeLink: tokens.shade.c600,
badge: tokens.shade.c900,
type: {
secondary: tokens.shade.c200,
inactive: tokens.shade.c50,
icon: tokens.shade.c50,
iconActivated: tokens.purple.c200,
activated: tokens.purple.c50,
},
},
card: {
border: tokens.shade.c400,
background: tokens.shade.c400,
altBackground: tokens.shade.c400,
},
saveBar: {
background: tokens.shade.c800,
},
},
utils: {
divider: tokens.ash.c300,
},
errors: {
card: tokens.shade.c800,
border: tokens.ash.c500,
type: {
secondary: tokens.ash.c100,
},
},
about: {
circle: tokens.ash.c500,
circleText: tokens.ash.c50,
},
editBadge: {
bg: tokens.ash.c500,
bgHover: tokens.ash.c400,
text: tokens.ash.c50,
},
progress: {
background: tokens.ash.c50,
preloaded: tokens.ash.c50,
filled: tokens.purple.c200,
},
video: {
buttonBackground: tokens.ash.c200,
autoPlay: {
background: tokens.ash.c700,
hover: tokens.ash.c500,
},
scraping: {
card: tokens.shade.c700,
loading: tokens.purple.c200,
noresult: tokens.ash.c100,
},
audio: {
set: tokens.purple.c200,
},
context: {
background: tokens.ash.c900,
light: tokens.shade.c50,
border: tokens.ash.c600,
hoverColor: tokens.ash.c600,
buttonFocus: tokens.ash.c500,
flagBg: tokens.ash.c500,
inputBg: tokens.ash.c600,
buttonOverInputHover: tokens.ash.c500,
inputPlaceholder: tokens.ash.c200,
cardBorder: tokens.ash.c700,
slider: tokens.ash.c50,
sliderFilled: tokens.purple.c200,
buttons: {
list: tokens.ash.c700,
active: tokens.ash.c900,
},
closeHover: tokens.ash.c800,
type: {
secondary: tokens.ash.c200,
accent: tokens.purple.c200,
},
},
},
},
},
});

View File

@@ -1,259 +0,0 @@
import { createTheme } from "../types";
const tokens = {
purple: {
c50: "#feabac",
c100: "#fe8385",
c200: "#ea5b5e",
c300: "#d34648",
c400: "#bd3436",
c500: "#852a2b",
c600: "#632021",
c700: "#49191a",
c800: "#331112",
c900: "#220c0c",
},
shade: {
c50: "#9c605c",
c100: "#834d49",
c200: "#673b38",
c300: "#542f2c",
c400: "#452422",
c500: "#361c1a",
c600: "#2b1614",
c700: "#241210",
c800: "#1c0e0d",
c900: "#130909",
},
ash: {
c50: "#ac6e6f",
c100: "#8b4b4c",
c200: "#703739",
c300: "#572225",
c400: "#49191a",
c500: "#421617",
c600: "#371212",
c700: "#2e0e0f",
c800: "#230c0d",
c900: "#19090b",
},
blue: {
c50: "#f5adb4",
c100: "#cc7981",
c200: "#ae5d65",
c300: "#8c3b43",
c400: "#712a31",
c500: "#501f24",
c600: "#411b1f",
c700: "#36171b",
c800: "#201011",
c900: "#130b0c",
},
};
export default createTheme({
name: "red",
extend: {
colors: {
themePreview: {
primary: tokens.blue.c200,
secondary: tokens.shade.c50,
},
pill: {
background: tokens.shade.c300,
backgroundHover: tokens.shade.c200,
highlight: tokens.blue.c200,
activeBackground: tokens.shade.c300,
},
global: {
accentA: tokens.blue.c200,
accentB: tokens.blue.c300,
},
lightBar: {
light: tokens.blue.c400,
},
buttons: {
toggle: tokens.purple.c300,
toggleDisabled: tokens.ash.c500,
secondary: tokens.ash.c700,
secondaryHover: tokens.ash.c700,
purple: tokens.purple.c500,
purpleHover: tokens.purple.c400,
cancel: tokens.ash.c500,
cancelHover: tokens.ash.c300,
},
background: {
main: tokens.shade.c900,
secondary: tokens.shade.c600,
secondaryHover: tokens.shade.c400,
accentA: tokens.purple.c500,
accentB: tokens.blue.c500,
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,
dimmed: tokens.shade.c50,
divider: tokens.ash.c500,
secondary: tokens.ash.c100,
link: tokens.purple.c100,
linkHover: tokens.purple.c50,
},
search: {
background: tokens.shade.c500,
hoverBackground: tokens.shade.c600,
focused: tokens.shade.c400,
placeholder: tokens.shade.c100,
icon: tokens.shade.c100,
},
mediaCard: {
hoverBackground: tokens.shade.c600,
hoverAccent: tokens.shade.c50,
hoverShadow: tokens.shade.c900,
shadow: tokens.shade.c700,
barColor: tokens.ash.c200,
barFillColor: tokens.purple.c100,
badge: tokens.shade.c700,
badgeText: tokens.ash.c100,
},
largeCard: {
background: tokens.shade.c600,
icon: tokens.purple.c400,
},
dropdown: {
background: tokens.shade.c600,
altBackground: tokens.shade.c700,
hoverBackground: tokens.shade.c500,
text: tokens.shade.c50,
secondary: tokens.shade.c100,
border: tokens.shade.c400,
contentBackground: tokens.shade.c500,
},
authentication: {
border: tokens.shade.c300,
inputBg: tokens.shade.c600,
inputBgHover: tokens.shade.c500,
wordBackground: tokens.shade.c500,
copyText: tokens.shade.c100,
copyTextHover: tokens.ash.c50,
},
settings: {
sidebar: {
activeLink: tokens.shade.c600,
badge: tokens.shade.c900,
type: {
secondary: tokens.shade.c200,
inactive: tokens.shade.c50,
icon: tokens.shade.c50,
iconActivated: tokens.purple.c200,
activated: tokens.purple.c50,
},
},
card: {
border: tokens.shade.c400,
background: tokens.shade.c400,
altBackground: tokens.shade.c400,
},
saveBar: {
background: tokens.shade.c800,
},
},
utils: {
divider: tokens.ash.c300,
},
errors: {
card: tokens.shade.c800,
border: tokens.ash.c500,
type: {
secondary: tokens.ash.c100,
},
},
about: {
circle: tokens.ash.c500,
circleText: tokens.ash.c50,
},
editBadge: {
bg: tokens.ash.c500,
bgHover: tokens.ash.c400,
text: tokens.ash.c50,
},
progress: {
background: tokens.ash.c50,
preloaded: tokens.ash.c50,
filled: tokens.purple.c200,
},
video: {
buttonBackground: tokens.ash.c200,
autoPlay: {
background: tokens.ash.c700,
hover: tokens.ash.c500,
},
scraping: {
card: tokens.shade.c700,
loading: tokens.purple.c200,
noresult: tokens.ash.c100,
},
audio: {
set: tokens.purple.c200,
},
context: {
background: tokens.ash.c900,
light: tokens.shade.c50,
border: tokens.ash.c600,
hoverColor: tokens.ash.c600,
buttonFocus: tokens.ash.c500,
flagBg: tokens.ash.c500,
inputBg: tokens.ash.c600,
buttonOverInputHover: tokens.ash.c500,
inputPlaceholder: tokens.ash.c200,
cardBorder: tokens.ash.c700,
slider: tokens.ash.c50,
sliderFilled: tokens.purple.c200,
buttons: {
list: tokens.ash.c700,
active: tokens.ash.c900,
},
closeHover: tokens.ash.c800,
type: {
secondary: tokens.ash.c200,
accent: tokens.purple.c200,
},
},
},
},
},
});

View File

@@ -1,259 +0,0 @@
import { createTheme } from "../types";
const tokens = {
purple: {
c50: "#aad7ff",
c100: "#82c4ff",
c200: "#59a8ec",
c300: "#4491d6",
c400: "#317dbf",
c500: "#285b87",
c600: "#1f4464",
c700: "#18334a",
c800: "#112434",
c900: "#0b1822",
},
shade: {
c50: "#677c90",
c100: "#52667a",
c200: "#3f4f60",
c300: "#32404f",
c400: "#273441",
c500: "#1e2832",
c600: "#172028",
c700: "#131a22",
c800: "#0f151b",
c900: "#0a0e12",
},
ash: {
c50: "#7f9b9b",
c100: "#5b7b7b",
c200: "#446463",
c300: "#2b4e4d",
c400: "#204241",
c500: "#1c3c3b",
c600: "#173232",
c700: "#132929",
c800: "#102020",
c900: "#0c1615",
},
blue: {
c50: "#adf5d6",
c100: "#79cca8",
c200: "#5dae8b",
c300: "#3b8c69",
c400: "#2a7152",
c500: "#1f503b",
c600: "#1b4130",
c700: "#173629",
c800: "#102019",
c900: "#0b1310",
},
};
export default createTheme({
name: "teal",
extend: {
colors: {
themePreview: {
primary: tokens.blue.c200,
secondary: tokens.shade.c50,
},
pill: {
background: tokens.shade.c300,
backgroundHover: tokens.shade.c200,
highlight: tokens.blue.c200,
activeBackground: tokens.shade.c300,
},
global: {
accentA: tokens.blue.c200,
accentB: tokens.blue.c300,
},
lightBar: {
light: tokens.blue.c400,
},
buttons: {
toggle: tokens.purple.c300,
toggleDisabled: tokens.ash.c500,
secondary: tokens.ash.c700,
secondaryHover: tokens.ash.c700,
purple: tokens.purple.c500,
purpleHover: tokens.purple.c400,
cancel: tokens.ash.c500,
cancelHover: tokens.ash.c300,
},
background: {
main: tokens.shade.c900,
secondary: tokens.shade.c600,
secondaryHover: tokens.shade.c400,
accentA: tokens.purple.c500,
accentB: tokens.blue.c500,
},
modal: {
background: tokens.shade.c800,
},
type: {
logo: tokens.purple.c100,
text: tokens.shade.c50,
dimmed: tokens.shade.c50,
divider: tokens.ash.c500,
secondary: tokens.ash.c100,
link: tokens.purple.c100,
linkHover: tokens.purple.c50,
},
search: {
background: tokens.shade.c500,
hoverBackground: tokens.shade.c600,
focused: tokens.shade.c400,
placeholder: tokens.shade.c100,
icon: tokens.shade.c100,
},
mediaCard: {
hoverBackground: tokens.shade.c600,
hoverAccent: tokens.shade.c50,
hoverShadow: tokens.shade.c900,
shadow: tokens.shade.c700,
barColor: tokens.ash.c200,
barFillColor: tokens.purple.c100,
badge: tokens.shade.c700,
badgeText: tokens.ash.c100,
},
largeCard: {
background: tokens.shade.c600,
icon: tokens.purple.c400,
},
dropdown: {
background: tokens.shade.c600,
altBackground: tokens.shade.c700,
hoverBackground: tokens.shade.c500,
text: tokens.shade.c50,
secondary: tokens.shade.c100,
border: tokens.shade.c400,
contentBackground: tokens.shade.c500,
},
authentication: {
border: tokens.shade.c300,
inputBg: tokens.shade.c600,
inputBgHover: tokens.shade.c500,
wordBackground: tokens.shade.c500,
copyText: tokens.shade.c100,
copyTextHover: tokens.ash.c50,
},
settings: {
sidebar: {
activeLink: tokens.shade.c600,
badge: tokens.shade.c900,
type: {
secondary: tokens.shade.c200,
inactive: tokens.shade.c50,
icon: tokens.shade.c50,
iconActivated: tokens.purple.c200,
activated: tokens.purple.c50,
},
},
card: {
border: tokens.shade.c400,
background: tokens.shade.c400,
altBackground: tokens.shade.c400,
},
saveBar: {
background: tokens.shade.c800,
},
},
utils: {
divider: tokens.ash.c300,
},
errors: {
card: tokens.shade.c800,
border: tokens.ash.c500,
type: {
secondary: tokens.ash.c100,
},
},
about: {
circle: tokens.ash.c500,
circleText: tokens.ash.c50,
},
editBadge: {
bg: tokens.ash.c500,
bgHover: tokens.ash.c400,
text: tokens.ash.c50,
},
progress: {
background: tokens.ash.c50,
preloaded: tokens.ash.c50,
filled: tokens.purple.c200,
},
video: {
buttonBackground: tokens.ash.c200,
autoPlay: {
background: tokens.ash.c700,
hover: tokens.ash.c500,
},
scraping: {
card: tokens.shade.c700,
loading: tokens.purple.c200,
noresult: tokens.ash.c100,
},
audio: {
set: tokens.purple.c200,
},
context: {
background: tokens.ash.c900,
light: tokens.shade.c50,
border: tokens.ash.c600,
hoverColor: tokens.ash.c600,
buttonFocus: tokens.ash.c500,
flagBg: tokens.ash.c500,
inputBg: tokens.ash.c600,
buttonOverInputHover: tokens.ash.c500,
inputPlaceholder: tokens.ash.c200,
cardBorder: tokens.ash.c700,
slider: tokens.ash.c50,
sliderFilled: tokens.purple.c200,
buttons: {
list: tokens.ash.c700,
active: tokens.ash.c900,
},
closeHover: tokens.ash.c800,
type: {
secondary: tokens.ash.c200,
accent: tokens.purple.c200,
},
},
},
},
},
});

View File

@@ -1,18 +0,0 @@
import type { defaultTheme } from "./default";
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export interface Theme {
name: string;
extend: DeepPartial<(typeof defaultTheme)["extend"]>;
}
export function createTheme(theme: Theme) {
return {
name: theme.name,
selectors: [`.theme-${theme.name}`],
extend: theme.extend,
};
}

View File

@@ -1,8 +0,0 @@
{
"extends": "@movie-web/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["."],
"exclude": ["node_modules"],
}