fix: design

This commit is contained in:
Fredrik Burmester
2024-08-27 22:32:09 +02:00
parent b550d6302f
commit 0d07f7216c
22 changed files with 303 additions and 162 deletions

View File

@@ -42,7 +42,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">

View File

@@ -55,7 +55,7 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">

View File

@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur";
import React, { useEffect } from "react";
import { View } from "react-native";
import { View, ViewProps } from "react-native";
import GoogleCast, {
CastButton,
useCastDevice,
@@ -8,16 +8,17 @@ import GoogleCast, {
useRemoteMediaClient,
} from "react-native-google-cast";
type Props = {
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
};
}
export const Chromecast: React.FC<Props> = ({
width = 48,
height = 48,
background = "transparent",
...props
}) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
@@ -37,7 +38,10 @@ export const Chromecast: React.FC<Props> = ({
if (background === "transparent")
return (
<View className=" rounded h-10 aspect-square flex items-center justify-center">
<View
className=" rounded h-10 aspect-square flex items-center justify-center"
{...props}
>
<CastButton style={{ tintColor: "white", height, width }} />
</View>
);
@@ -46,6 +50,7 @@ export const Chromecast: React.FC<Props> = ({
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<CastButton style={{ tintColor: "white", height, width }} />
</BlurView>

View File

@@ -26,7 +26,7 @@ import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
interface DownloadProps extends TouchableOpacityProps {
interface DownloadProps extends ViewProps {
item: BaseItemDto;
}
@@ -143,23 +143,19 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
enabled: !!item.Id,
});
if (isFetching) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{isFetching ? (
<Loader />
</View>
);
}
if (process && process?.item.Id === item.Id) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
) : process && process?.item.Id === item.Id ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
{process.progress === 0 ? (
<Loader />
) : (
@@ -173,61 +169,41 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
/>
</View>
)}
</View>
</TouchableOpacity>
);
}
if (queue.some((i) => i.id === item.Id)) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
</TouchableOpacity>
) : queue.some((i) => i.id === item.Id) ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="hourglass" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
if (downloaded) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</View>
</TouchableOpacity>
);
} else {
return (
<TouchableOpacity
onPress={() => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
// await startRemuxing(playbackUrl);
if (!settings?.downloadQuality?.value) {
throw new Error("No download quality selected");
}
await initiateDownload(settings?.downloadQuality?.value);
},
item,
});
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download-outline" size={26} color="white" />
</View>
</TouchableOpacity>
);
}
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
if (!settings?.downloadQuality?.value) {
throw new Error("No download quality selected");
}
await initiateDownload(settings?.downloadQuality?.value);
},
item,
});
}}
>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
)}
</View>
);
};

View File

@@ -5,16 +5,12 @@ import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { Ratings } from "@/components/Ratings";
import { SimilarItems } from "@/components/SimilarItems";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import { EpisodeTitleHeader } from "@/components/series/EpisodeTitleHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
@@ -24,16 +20,19 @@ import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useImageColors } from "@/hooks/useImageColors";
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const [api] = useAtom(apiAtom);
@@ -41,6 +40,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const [settings] = useSettings();
const castDevice = useCastDevice();
const navigation = useNavigation();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
@@ -52,22 +52,45 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
value: undefined,
});
const headerHeightRef = useRef(0);
const {
data: item,
isLoading,
isFetching,
} = useQuery({
queryKey: ["item", id],
queryFn: async () =>
await getUserItemData({
queryFn: async () => {
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: id,
}),
});
return res;
},
enabled: !!id && !!api,
staleTime: 60 * 1000,
});
useEffect(() => {
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
<DownloadItem item={item} />
<PlayedStatus item={item} />
</View>
),
});
}, [item]);
useEffect(() => {
if (item?.Type === "Episode") headerHeightRef.current = 400;
else headerHeightRef.current = 500;
}, [item]);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
@@ -139,12 +162,14 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
return (
<ParallaxScrollView
headerHeight={300}
headerHeight={headerHeightRef.current}
headerImage={
<>
{item ? (
<ItemImage
variant={item.Type === "Movie" ? "Backdrop" : "Primary"}
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
@@ -179,23 +204,12 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
</>
}
>
<View className="flex flex-col">
<View className="flex flex-col px-4 w-full space-y-1 pt-4">
<ItemHeader item={item} />
<View className="flex flex-col bg-transparent">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2">
<ItemHeader item={item} className="mb-4" />
{item ? (
<View className="flex flex-row justify-between items-center mb-2">
<DownloadItem item={item} />
<PlayedStatus item={item} />
</View>
) : (
<View>
<View className="bg-neutral-950 h-8 w-full rounded-lg my-2"></View>
</View>
)}
{item ? (
<View className="flex flex-row items-center space-x-2 w-full mb-1">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
@@ -227,7 +241,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
</View>
)}
<PlayButton item={item} url={playbackUrl} className="grow mb-2" />
<PlayButton item={item} url={playbackUrl} className="grow" />
</View>
{item?.Type === "Episode" && (

View File

@@ -21,10 +21,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
);
return (
<View {...props}>
<View className="flex flex-col" {...props}>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
<Ratings item={item} />
</View>
);
};

View File

@@ -34,14 +34,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
useEffect(() => {
if (mediaSources?.length) onChange(mediaSources[0]);
}, []);
}, [mediaSources]);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">Video streams</Text>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">{tc(selectedMediaSource, 7)}</Text>
@@ -58,7 +58,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Video streams</DropdownMenu.Label>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{mediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}

View File

@@ -1,3 +1,4 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
import { View } from "react-native";
import Animated, {
@@ -57,7 +58,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
{logo && (
<View
style={{
top: headerHeight - 150,
top: headerHeight - 200,
height: 130,
}}
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
@@ -66,14 +67,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
</View>
)}
{episodePoster && (
<View className="absolute top-[290px] h-[120px] w-full left-0 flex justify-center items-center z-50">
<View className="h-full aspect-video border border-neutral-800">
{episodePoster}
</View>
</View>
)}
<Animated.View
style={[
{
@@ -86,7 +79,34 @@ export const ParallaxScrollView: React.FC<Props> = ({
{headerImage}
</Animated.View>
<View className="flex-1 overflow-hidden bg-black pb-24">
<View
style={{
top: -50,
}}
className="relative flex-1 bg-transparent pb-24"
>
<LinearGradient
// Background Linear Gradient
colors={["transparent", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: -150,
height: 200,
}}
/>
<View
// Background Linear Gradient
style={{
position: "absolute",
left: 0,
right: 0,
top: 50,
height: "100%",
backgroundColor: "black",
}}
/>
{children}
</View>
</Animated.ScrollView>

View File

@@ -11,6 +11,8 @@ import CastContext, {
} from "react-native-google-cast";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useAtom } from "jotai";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
@@ -22,6 +24,8 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const [color] = useAtom(itemThemeColorAtom);
const onPress = async () => {
if (!url || !item) return;
@@ -88,23 +92,30 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
? "100%"
: `${Math.max(playbackPercent, 15)}%`,
height: "100%",
backgroundColor: color.primary,
}}
className="absolute w-full h-full top-0 left-0 rounded-xl bg-purple-600 z-10"
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
></View>
<View
style={{
height: "100%",
width: "100%",
backgroundColor: color.primary,
}}
className="absolute w-full h-full top-0 left-0 rounded-xl bg-purple-500 opacity-40"
className="absolute w-full h-full top-0 left-0 rounded-xl opacity-40"
></View>
<View className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full ">
<View className="flex flex-row items-center space-x-2">
<Text className="font-bold">
<Text
className="font-bold"
style={{
color: color.text,
}}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Text>
<Ionicons name="play-circle" size={24} color="white" />
{client && <Feather name="cast" size={22} color="white" />}
<Ionicons name="play-circle" size={24} color={color.text} />
{client && <Feather name="cast" size={22} color={color.text} />}
</View>
</View>
</TouchableOpacity>

View File

@@ -7,9 +7,13 @@ import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import { TouchableOpacity, View, ViewProps } from "react-native";
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
interface Props extends ViewProps {
item: BaseItemDto;
}
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -37,7 +41,10 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
};
return (
<View>
<View
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{item.UserData?.Played ? (
<TouchableOpacity
onPress={async () => {
@@ -51,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={30} color="white" />
<Ionicons name="checkmark-circle" size={24} color="white" />
</View>
</TouchableOpacity>
) : (
@@ -67,7 +74,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
</View>
</TouchableOpacity>
)}

View File

@@ -8,10 +8,10 @@ interface Props extends ViewProps {
item?: BaseItemDto | null;
}
export const Ratings: React.FC<Props> = ({ item }) => {
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
if (!item) return null;
return (
<View className="flex flex-row items-center justify-center mt-2 space-x-2">
<View className="flex flex-row items-center mt-2 space-x-2" {...props}>
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant="gray" />
)}

View File

@@ -48,7 +48,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
@@ -69,7 +69,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
<DropdownMenu.Item
key={"-1"}
onSelect={() => {

View File

@@ -36,7 +36,7 @@ export const HeaderBackButton: React.FC<Props> = ({
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="#077DF2"
color="white"
/>
</TouchableOpacity>
</BlurView>

View File

@@ -1,10 +1,11 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, ImageProps, ImageSource } from "expo-image";
import { useMemo, useState } from "react";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useEffect, useMemo } from "react";
import { getColors } from "react-native-image-colors";
interface Props extends ImageProps {
item: BaseItemDto;
@@ -81,6 +82,8 @@ export const ItemImage: React.FC<Props> = ({
return src;
}, [item.ImageTags]);
useImageColors(source?.uri);
return (
<Image
transition={300}

View File

@@ -8,9 +8,9 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View className="flex flex-col" {...props}>
<Text className="text-center font-bold text-2xl">{item?.Name}</Text>
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<View {...props}>
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
<Text className=" opacity-50">{item?.ProductionYear}</Text>
</View>
);
};

View File

@@ -11,14 +11,9 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<View {...props} className="flex flex-col">
<TouchableOpacity
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
>
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
</TouchableOpacity>
<Text className="text-center font-bold text-2xl">{item?.Name}</Text>
<View className="flex flex-row items-center self-center">
<View {...props}>
<Text className="font-bold text-2xl">{item?.Name}</Text>
<View className="flex flex-row items-center mb-1">
<TouchableOpacity
onPress={() => {
router.push(
@@ -27,14 +22,13 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
);
}}
>
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
<Text className="opacity-50">{item?.SeasonName}</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
<Text className="opacity-50 mx-2">{"—"}</Text>
<Text className="opacity-50">{`Episode ${item.IndexNumber}`}</Text>
</View>
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<Text className="opacity-50">{item?.ProductionYear}</Text>
</View>
);
};

View File

@@ -107,14 +107,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}, [episodes, api, user?.Id, item]);
useEffect(() => {
if (item?.Type === "Episode") {
if (item?.Type === "Episode" && item.Id) {
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
if (index !== undefined && index !== -1) {
setTimeout(() => {
scrollToIndex(index);
}, 400);
} else {
console.warn("Episode not found in the list:", item.Id);
}
}
}, [episodes, item]);

View File

@@ -8,7 +8,6 @@ const commonScreenOptions = {
headerTransparent: true,
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerRight: () => <Chromecast background="blur" width={22} height={22} />,
};
const routes = [

42
hooks/useImageColors.ts Normal file
View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { getColors } from "react-native-image-colors";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useAtom } from "jotai";
export const useImageColors = (uri: string | undefined | null) => {
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
useEffect(() => {
if (uri) {
getColors(uri, {
fallback: "#fff",
cache: true,
key: uri,
})
.then((colors) => {
let primary: string = "#fff";
let average: string = "#fff";
let secondary: string = "#fff";
if (colors.platform === "android") {
primary = colors.dominant;
average = colors.average;
secondary = colors.muted;
} else if (colors.platform === "ios") {
primary = colors.primary;
secondary = colors.detail;
average = colors.background;
}
setPrimaryColor({
primary,
secondary,
average,
});
})
.catch((error) => {
console.error("Error getting colors", error);
});
}
}, [uri, setPrimaryColor]);
};

View File

@@ -0,0 +1,73 @@
import { atom, useAtom } from "jotai";
interface ThemeColors {
primary: string;
secondary: string;
average: string;
text: string;
}
const calculateTextColor = (backgroundColor: string): string => {
// Convert hex to RGB
const r = parseInt(backgroundColor.slice(1, 3), 16);
const g = parseInt(backgroundColor.slice(3, 5), 16);
const b = parseInt(backgroundColor.slice(5, 7), 16);
// Calculate perceived brightness
// Using the formula: (R * 299 + G * 587 + B * 114) / 1000
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
// Calculate contrast ratio with white and black
const contrastWithWhite = calculateContrastRatio([255, 255, 255], [r, g, b]);
const contrastWithBlack = calculateContrastRatio([0, 0, 0], [r, g, b]);
// Use black text if the background is bright and has good contrast with black
if (brightness > 180 && contrastWithBlack >= 4.5) {
return "#000000";
}
// Otherwise, use white text
return "#FFFFFF";
};
// Helper function to calculate contrast ratio
const calculateContrastRatio = (rgb1: number[], rgb2: number[]): number => {
const l1 = calculateRelativeLuminance(rgb1);
const l2 = calculateRelativeLuminance(rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
};
// Helper function to calculate relative luminance
const calculateRelativeLuminance = (rgb: number[]): number => {
const [r, g, b] = rgb.map((c) => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const baseThemeColorAtom = atom<ThemeColors>({
primary: "#FFFFFF",
secondary: "#000000",
average: "#888888",
text: "#000000",
});
export const itemThemeColorAtom = atom(
(get) => get(baseThemeColorAtom),
(get, set, update: Partial<ThemeColors>) => {
const currentColors = get(baseThemeColorAtom);
const newColors = { ...currentColors, ...update };
// Recalculate text color if primary color changes
if (update.primary) {
newColors.text = calculateTextColor(update.primary);
}
set(baseThemeColorAtom, newColors);
}
);
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);

View File

@@ -34,7 +34,6 @@ export const getStreamUrl = async ({
mediaSourceId?: string | null;
}) => {
if (!api || !userId || !item?.Id || !mediaSourceId) {
console.error("Missing required parameters");
return null;
}

View File

@@ -25,7 +25,7 @@ export const postCapabilities = async ({
sessionId,
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing required parameters");
throw new Error("Missing parameters for marking item as not played");
}
try {