Use tailwind in player

This commit is contained in:
Zoe Roux 2026-02-05 10:23:38 +01:00
parent d587d119fd
commit 546935d988
No known key found for this signature in database
12 changed files with 164 additions and 319 deletions

View File

@ -1,15 +1,18 @@
import { ActivityIndicator } from "react-native";
import { type Stylable, useYoshiki } from "yoshiki/native";
import { cn } from "~/utils";
export const CircularProgress = ({
size = 48,
tickness = 5,
color,
...props
}: { size?: number; tickness?: number; color?: string } & Stylable) => {
const { theme } = useYoshiki();
}: {
tickness?: number;
className?: string;
}) => {
return (
<ActivityIndicator size={size} color={color ?? theme.accent} {...props} />
<ActivityIndicator
colorClassName={cn("accent-accent")}
size="large"
{...props}
/>
);
};

View File

@ -5,8 +5,7 @@ import {
View,
type ViewProps,
} from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native";
import { focusReset } from "./utils";
import { cn } from "~/utils";
export const Slider = ({
progress,
@ -17,7 +16,7 @@ export const Slider = ({
startSeek,
endSeek,
onHover,
size = 6,
className,
...props
}: {
progress: number;
@ -31,9 +30,7 @@ export const Slider = ({
position: number | null,
layout: { x: number; y: number; width: number; height: number },
) => void;
size?: number;
} & Partial<ViewProps>) => {
const { css } = useYoshiki();
const ref = useRef<View>(null);
const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [isSeeking, setSeek] = useState(false);
@ -41,8 +38,6 @@ export const Slider = ({
const [isFocus, setFocus] = useState(false);
const smallBar = !(isSeeking || isHover || isFocus);
const ts = (value: number) => px(value * size);
const change = (event: GestureResponderEvent) => {
event.preventDefault();
const locationX = Platform.select({
@ -57,7 +52,6 @@ export const Slider = ({
ref={ref}
// @ts-expect-error Web only
onMouseEnter={() => setHover(true)}
// @ts-expect-error Web only
onMouseLeave={() => {
setHover(false);
onHover?.(null, layout);
@ -104,98 +98,39 @@ export const Slider = ({
break;
}
}}
{...css(
{
paddingVertical: ts(1),
// @ts-expect-error Web only
cursor: "pointer",
...focusReset,
},
props,
)}
className={cn("cursor-pointer justify-center py-2 outline-0", className)}
{...props}
>
<View
{...css([
{
width: percent(100),
height: ts(1),
bg: (theme) => theme.overlay0,
},
smallBar && { transform: "scaleY(0.4)" as any },
])}
className={cn(
"h-2 w-full overflow-hidden rounded bg-slate-400",
smallBar && "scale-y-50",
)}
>
{subtleProgress !== undefined && (
<View
{...css(
{
bg: (theme) => theme.overlay1,
position: "absolute",
top: 0,
bottom: 0,
left: 0,
},
{
style: {
width: percent((subtleProgress / max) * 100),
},
},
)}
className={cn("absolute left-0 h-full bg-slate-300")}
style={{ width: `${(subtleProgress / max) * 100}%` }}
/>
)}
<View
{...css(
{
bg: (theme) => theme.accent,
position: "absolute",
top: 0,
bottom: 0,
left: 0,
},
{
// In an inline style because yoshiki's insertion can not catch up with the constant redraw
style: {
width: percent((progress / max) * 100),
},
},
)}
className="absolute left-0 h-full bg-accent"
style={{ width: `${(progress / max) * 100}%` }}
/>
{markers?.map((x) => (
<View
key={x}
{...css({
position: "absolute",
top: 0,
bottom: 0,
left: percent(Math.min(100, (x / max) * 100)),
bg: (theme) => theme.accent,
width: ts(0.5),
height: ts(1),
})}
className="absolute h-full w-1 bg-accent"
style={{ left: `${Math.min(100, (x / max) * 100)}%` }}
/>
))}
</View>
<View
{...css(
[
{
position: "absolute",
top: 0,
bottom: 0,
marginY: ts(0.5),
bg: (theme) => theme.accent,
width: ts(2),
height: ts(2),
borderRadius: ts(1),
marginLeft: ts(-1),
},
smallBar && { opacity: 0 },
],
{
style: {
left: percent((progress / max) * 100),
},
},
className={cn(
"absolute my-1 ml-[-6px] h-3 w-3 rounded-full bg-accent",
smallBar && "opacity-0",
)}
style={{ left: `${(progress / max) * 100}%` }}
/>
</View>
);

View File

@ -2,7 +2,6 @@ import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { percent, rem, useYoshiki } from "yoshiki/native";
import {
H1,
IconButton,
@ -10,29 +9,19 @@ import {
Skeleton,
tooltip,
} from "~/primitives";
import { cn } from "~/utils";
export const Back = ({
name,
showHref,
className,
...props
}: { showHref?: string; name?: string } & ViewProps) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const router = useRouter();
return (
<View
{...css(
{
display: "flex",
flexDirection: "row",
alignItems: "center",
padding: percent(0.33),
color: "white",
},
props,
)}
>
<View className={cn("flex-row items-center", className)} {...props}>
<IconButton
icon={ArrowBack}
as={PressableFeedback}
@ -40,20 +29,14 @@ export const Back = ({
if (router.canGoBack()) router.back();
else if (showHref) router.navigate(showHref);
}}
className="my-4 ml-4"
iconClassName="fill-slate-200"
{...tooltip(t("player.back"))}
/>
{name ? (
<H1
{...css({
alignSelf: "center",
fontSize: rem(1.5),
marginLeft: rem(1),
})}
>
{name}
</H1>
<H1 className="my-4 ml-4 text-2xl text-slate-200">{name}</H1>
) : (
<Skeleton {...css({ width: rem(5) })} />
<Skeleton className="my-4 w-20" />
)}
</View>
);

View File

@ -2,9 +2,13 @@ import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import {
Platform,
type PressableProps,
View,
type ViewProps,
} from "react-native";
import type { VideoPlayer } from "react-native-video";
import { percent, rem, useYoshiki } from "yoshiki/native";
import type { Chapter, KImage } from "~/models";
import {
H2,
@ -14,9 +18,9 @@ import {
Poster,
Skeleton,
tooltip,
ts,
useIsTouch,
} from "~/primitives";
import { cn } from "~/utils";
import { FullscreenButton, PlayButton, VolumeSlider } from "./misc";
import { ProgressBar, ProgressText } from "./progress";
import { AudioMenu, QualityMenu, SubtitleMenu, VideoMenu } from "./tracks-menu";
@ -29,6 +33,7 @@ export const BottomControls = ({
previous,
next,
setMenu,
className,
...props
}: {
player: VideoPlayer;
@ -39,52 +44,26 @@ export const BottomControls = ({
next?: string | null;
setMenu: (isOpen: boolean) => void;
} & ViewProps) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
flexDirection: "row",
padding: ts(1),
},
props,
)}
>
<View
{...css({
width: "15%",
display: { xs: "none", sm: "flex" },
position: "relative",
})}
>
<View className={cn("flex-row p-2", className)} {...props}>
<View className="m-4 w-1/5 max-w-50 max-sm:hidden">
{poster !== undefined ? (
<Poster
src={poster}
quality="low"
layout={{ width: percent(100) }}
{...(css({ position: "absolute", bottom: 0 }) as any)}
className="absolute bottom-0 w-full"
/>
) : (
<Poster.Loader
layout={{ width: percent(100) }}
{...(css({ position: "absolute", bottom: 0 }) as any)}
/>
<Poster.Loader className="absolute bottom-0 w-full" />
)}
</View>
<View
{...css({
marginHorizontal: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column",
flex: 1,
})}
>
<View className="my-1 mr-4 flex-1 max-sm:ml-4 sm:my-6">
{name ? (
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
<H2 numberOfLines={1} className="pb-2 text-slate-200">
{name}
</H2>
) : (
<Skeleton {...css({ width: rem(15), height: rem(2) })} />
<Skeleton className="h-8 w-1/5" />
)}
<ProgressBar player={player} chapters={chapters} />
<ControlButtons
@ -103,71 +82,79 @@ const ControlButtons = ({
previous,
next,
setMenu,
className,
...props
}: {
player: VideoPlayer;
previous?: string | null;
next?: string | null;
setMenu: (isOpen: boolean) => void;
className?: string;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const isTouch = useIsTouch();
const spacing = css({ marginHorizontal: ts(1) });
const menuProps = {
onMenuOpen: () => setMenu(true),
onMenuClose: () => setMenu(false),
...spacing,
} satisfies Partial<ComponentProps<typeof Menu>>;
className: "mr-4",
iconClassName: "fill-slate-200",
} satisfies Partial<
ComponentProps<
typeof Menu<ComponentProps<typeof IconButton<PressableProps>>>
>
>;
return (
<View
{...css(
{
flexDirection: "row",
flex: 1,
justifyContent: "space-between",
flexWrap: "wrap",
},
props,
)}
className={cn("flex-1 flex-row flex-wrap justify-between", className)}
{...props}
>
<View {...css({ flexDirection: "row" })}>
<View className="flex-row items-center">
{!isTouch && (
<View {...css({ flexDirection: "row" })}>
<View className="flex-row">
{previous && (
<IconButton
icon={SkipPrevious}
as={Link}
href={`/watch/${previous}`}
replace
className="mr-4"
iconClassName="fill-slate-200"
{...tooltip(t("player.previous"), true)}
{...spacing}
/>
)}
<PlayButton player={player} {...spacing} />
<PlayButton
player={player}
className="mr-4"
iconClassName="fill-slate-200"
/>
{next && (
<IconButton
icon={SkipNext}
as={Link}
href={`/watch/${next}`}
replace
className="mr-4"
iconClassName="fill-slate-200"
{...tooltip(t("player.next"), true)}
{...spacing}
/>
)}
{Platform.OS === "web" && <VolumeSlider player={player} />}
{Platform.OS === "web" && (
<VolumeSlider player={player} iconClassName="fill-slate-200" />
)}
</View>
)}
<ProgressText player={player} {...spacing} />
<ProgressText player={player} className="mx-2 text-slate-300" />
</View>
<View {...css({ flexDirection: "row" })}>
<View className="flex-row">
<SubtitleMenu player={player} {...menuProps} />
<AudioMenu player={player} {...menuProps} />
<VideoMenu player={player} {...menuProps} />
<QualityMenu player={player} {...menuProps} />
{Platform.OS === "web" && <FullscreenButton {...spacing} />}
{Platform.OS === "web" && (
<FullscreenButton className="mr-4" iconClassName="fill-slate-200" />
)}
</View>
</View>
);

View File

@ -1,9 +1,7 @@
import { useCallback, useState } from "react";
import type { ViewProps } from "react-native";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { View } from "react-native";
import type { VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import type { Chapter, KImage } from "~/models";
import { useIsTouch } from "~/primitives";
import { Back } from "./back";
@ -30,8 +28,6 @@ export const Controls = ({
previous?: string | null;
next?: string | null;
}) => {
const { css } = useYoshiki();
const insets = useSafeAreaInsets();
const isTouch = useIsTouch();
const [hover, setHover] = useState(false);
@ -53,28 +49,17 @@ export const Controls = ({
}, []);
return (
<View {...css(StyleSheet.absoluteFillObject)}>
<View className="absolute inset-0">
<TouchControls
player={player}
forceShow={hover || menuOpened}
{...css(StyleSheet.absoluteFillObject)}
className="absolute inset-0"
>
<Back
showHref={showHref}
name={name}
{...css(
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bg: (theme) => theme.darkOverlay,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
},
hoverControls,
)}
className="absolute top-0 w-full bg-slate-900/50 px-safe pt-safe"
{...hoverControls}
/>
{isTouch && (
<MiddleControls player={player} previous={previous} next={next} />
@ -87,21 +72,10 @@ export const Controls = ({
previous={previous}
next={next}
setMenu={setMenu}
{...css(
{
// Fixed is used because firefox android make the hover disappear under the navigation bar in absolute
// position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
position: "absolute",
bottom: 0,
left: 0,
right: 0,
bg: (theme) => theme.darkOverlay,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
},
hoverControls,
)}
// Fixed is used because firefox android make the hover disappear under the navigation bar in absolute
// position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
className="absolute bottom-0 w-full bg-slate-900/50 px-safe pt-safe"
{...hoverControls}
/>
</TouchControls>
</View>

View File

@ -2,59 +2,53 @@ import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
import { View } from "react-native";
import type { VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import { IconButton, Link, ts } from "~/primitives";
import { IconButton, Link } from "~/primitives";
import { cn } from "~/utils";
import { PlayButton } from "./misc";
export const MiddleControls = ({
player,
previous,
next,
className,
...props
}: {
player: VideoPlayer;
previous?: string | null;
next?: string | null;
className?: string;
}) => {
const { css } = useYoshiki();
const common = css({
backgroundColor: (theme) => theme.darkOverlay,
marginHorizontal: ts(3),
});
return (
<View
{...css(
{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
props,
className={cn(
"absolute inset-0 flex-row items-center justify-center",
className,
)}
{...props}
>
<IconButton
icon={SkipPrevious}
as={Link}
href={previous ?? ""}
href={previous}
replace
size={ts(4)}
{...css([!previous && { opacity: 0, pointerEvents: "none" }], common)}
className={cn(
"mx-12 h-16 w-16 bg-gray-800/70",
!previous && "pointer-events-none opacity-0",
)}
/>
<PlayButton
player={player}
className={cn("mx-12 h-32 w-32 bg-gray-800/70")}
/>
<PlayButton player={player} size={ts(8)} {...common} />
<IconButton
icon={SkipNext}
as={Link}
href={next ?? ""}
href={next}
replace
size={ts(4)}
{...css([!next && { opacity: 0, pointerEvents: "none" }], common)}
className={cn(
"mx-12 h-16 w-16 bg-gray-800/70",
!next && "pointer-events-none opacity-0",
)}
/>
</View>
);

View File

@ -10,15 +10,8 @@ import { type ComponentProps, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { type PressableProps, View } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { px, useYoshiki } from "yoshiki/native";
import {
alpha,
CircularProgress,
IconButton,
Slider,
tooltip,
ts,
} from "~/primitives";
import { CircularProgress, IconButton, Slider, tooltip } from "~/primitives";
import { cn } from "~/utils";
export const PlayButton = ({
player,
@ -85,8 +78,16 @@ export const FullscreenButton = (
);
};
export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
const { css } = useYoshiki();
export const VolumeSlider = ({
player,
className,
iconClassName,
...props
}: {
player: VideoPlayer;
className?: string;
iconClassName?: string;
}) => {
const { t } = useTranslation();
const [volume, setVolume] = useState(player.volume);
@ -98,15 +99,8 @@ export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
return (
<View
{...css(
{
display: { xs: "none", sm: "flex" },
alignItems: "center",
flexDirection: "row",
paddingRight: ts(1),
},
props,
)}
className={cn("flex-row items-center pr-2 max-sm:hidden", className)}
{...props}
>
<IconButton
icon={
@ -121,6 +115,7 @@ export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
onPress={() => {
player.muted = !muted;
}}
iconClassName={iconClassName}
{...tooltip(t("player.mute"), true)}
/>
<Slider
@ -128,8 +123,7 @@ export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
setProgress={(vol) => {
player.volume = vol / 100;
}}
size={4}
{...css({ width: px(100) })}
className="h-1 w-24"
{...tooltip(t("player.volume"), true)}
/>
</View>
@ -137,7 +131,6 @@ export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
};
export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => {
const { css } = useYoshiki();
const [isLoading, setLoading] = useState(false);
useEvent(player, "onStatusChange", (status) => {
@ -147,19 +140,8 @@ export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => {
if (!isLoading) return null;
return (
<View
{...css({
position: "absolute",
pointerEvents: "none",
top: 0,
bottom: 0,
left: 0,
right: 0,
bg: (theme) => alpha(theme.colors.black, 0.3),
justifyContent: "center",
})}
>
<CircularProgress {...css({ alignSelf: "center" })} />
<View className="pointer-events-none absolute inset-0 justify-center bg-slate-900/30">
<CircularProgress className="self-center" />
</View>
);
};

View File

@ -1,9 +1,9 @@
import { useState } from "react";
import type { TextProps } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import type { Chapter } from "~/models";
import { P, Slider } from "~/primitives";
import { cn } from "~/utils";
export const ProgressBar = ({
player,
@ -76,10 +76,9 @@ export const ProgressBar = ({
export const ProgressText = ({
player,
className,
...props
}: { player: VideoPlayer } & TextProps) => {
const { css } = useYoshiki();
const [progress, setProgress] = useState(player.currentTime);
useEvent(player, "onProgress", (progress) => {
setProgress(progress.currentTime);
@ -90,7 +89,7 @@ export const ProgressText = ({
});
return (
<P {...css({ alignSelf: "center" }, props)}>
<P className={cn("text-center", className)} {...props}>
{toTimerString(progress, duration)} : {toTimerString(duration)}
</P>
);

View File

@ -4,13 +4,12 @@ import {
Platform,
Pressable,
type PressableProps,
StyleSheet,
View,
type ViewProps,
} from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import { useIsTouch } from "~/primitives";
import { cn } from "~/utils";
import { toggleFullscreen } from "./misc";
export const TouchControls = ({
@ -22,7 +21,6 @@ export const TouchControls = ({
player: VideoPlayer;
forceShow?: boolean;
} & ViewProps) => {
const { css } = useYoshiki();
const isTouch = useIsTouch();
const [playing, setPlay] = useState(player.isPlaying);
@ -31,7 +29,7 @@ export const TouchControls = ({
});
const [_show, setShow] = useState(false);
const hideTimeout = useRef<NodeJS.Timeout | null>(null);
const hideTimeout = useRef<NodeJS.Timeout | number | null>(null);
const shouldShow = forceShow || _show || !playing;
const show = useCallback((val: boolean = true) => {
setShow(val);
@ -90,10 +88,7 @@ export const TouchControls = ({
// instantly hide the controls when mouse leaves the view
if (e.nativeEvent.pointerType === "mouse") show(false);
}}
{...css({
cursor: (shouldShow ? "unset" : "none") as any,
...StyleSheet.absoluteFillObject,
})}
className={cn("absolute inset-0", !shouldShow && "cursor-none")}
/>
{shouldShow && children}
</View>
@ -107,7 +102,7 @@ const DoublePressable = ({
}: {
onDoublePress: (e: GestureResponderEvent) => boolean | undefined;
} & PressableProps) => {
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout | number }>({
count: 0,
});

View File

@ -5,12 +5,11 @@ import VideoSettings from "@material-symbols/svg-400/rounded/video_settings-fill
import { type ComponentProps, createContext, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useEvent, type VideoPlayer } from "react-native-video";
import { useForceRerender } from "yoshiki";
import { IconButton, Menu, tooltip } from "~/primitives";
import { useFetch } from "~/query";
import { useDisplayName, useSubtitleName } from "~/track-utils";
import { Info } from "~/ui/info";
import { useQueryState } from "~/utils";
import { useForceRerender, useQueryState } from "~/utils";
type MenuProps = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;

View File

@ -5,10 +5,9 @@ import { useCallback, useEffect, useState } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
import { v4 as uuidv4 } from "uuid";
import { useYoshiki } from "yoshiki/native";
import { entryDisplayNumber } from "~/components/entries";
import { FullVideo, type KyooError } from "~/models";
import { ContrastArea, Head } from "~/primitives";
import { Head } from "~/primitives";
import { useToken } from "~/providers/account-context";
import { useLocalSetting } from "~/providers/settings";
import { type QueryIdentifier, useFetch } from "~/query";
@ -158,14 +157,13 @@ export const Player = () => {
setPlayMode("hls");
else setPlaybackError({ status: error.code, message: error.message });
});
const { css } = useYoshiki();
if (error || infoError || playbackError) {
return (
<>
<Back
showHref={data?.show?.href}
name={data?.show?.name ?? "Error"}
{...css({ position: "relative", bg: (theme) => theme.accent })}
className="bg-accent"
/>
<ErrorView error={error ?? infoError ?? playbackError!} />
</>
@ -173,12 +171,7 @@ export const Player = () => {
}
return (
<View
style={{
flex: 1,
backgroundColor: "black",
}}
>
<View className="flex-1 bg-black">
<Head
title={title}
description={entry?.description}
@ -200,27 +193,25 @@ export const Player = () => {
resizeMode={"contain"}
style={StyleSheet.absoluteFillObject}
/>
<ContrastArea mode="dark">
<LoadingIndicator player={player} />
<PlayModeContext.Provider value={playModeState}>
<Controls
player={player}
showHref={data?.show?.href}
name={data?.show?.name}
poster={data?.show?.poster}
subName={
entry
? [entryDisplayNumber(entry), entry.name]
.filter((x) => x)
.join(" - ")
: undefined
}
chapters={info?.chapters ?? []}
previous={data?.previous?.video}
next={data?.next?.video}
/>
</PlayModeContext.Provider>
</ContrastArea>
<LoadingIndicator player={player} />
<PlayModeContext.Provider value={playModeState}>
<Controls
player={player}
showHref={data?.show?.href}
name={data?.show?.name}
poster={data?.show?.poster}
subName={
entry
? [entryDisplayNumber(entry), entry.name]
.filter((x) => x)
.join(" - ")
: undefined
}
chapters={info?.chapters ?? []}
previous={data?.previous?.video}
next={data?.next?.video}
/>
</PlayModeContext.Provider>
</View>
);
};

View File

@ -1,13 +1,16 @@
import { type ClassValue, clsx } from "clsx";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback } from "react";
import { useCallback, useReducer } from "react";
import { twMerge } from "tailwind-merge";
import type { Movie, Show } from "~/models";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const useForceRerender = () => {
return useReducer((x) => x + 1, 0)[1];
};
export function setServerData(_key: string, _val: any) {}
export function getServerData(key: string) {
return key;