mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
wip
This commit is contained in:
@@ -354,6 +354,7 @@ export default function index() {
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
rowGap: 8,
|
||||
}}
|
||||
style={{
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ import React from "react";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
width?: number;
|
||||
useEpisodePoster?: boolean;
|
||||
size?: "small" | "normal";
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
useEpisodePoster = false,
|
||||
size = "normal",
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
@@ -51,7 +52,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
<View
|
||||
className={`
|
||||
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
|
||||
${size === "small" ? "w-32" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
|
||||
@@ -10,7 +10,7 @@ type ItemCardProps = {
|
||||
|
||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
return (
|
||||
<View className="mt-2 flex flex-col h-12">
|
||||
<View className="mt-2 flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} className="">
|
||||
|
||||
@@ -125,6 +125,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
});
|
||||
|
||||
const [localItem, setLocalItem] = useState(item);
|
||||
useImageColors(item);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
@@ -234,18 +235,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
});
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
const themeImageColorSource = useMemo(() => {
|
||||
if (!api || !item) return;
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
useImageColors(themeImageColorSource?.uri);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
|
||||
@@ -274,7 +263,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||
{localItem && (
|
||||
<ItemImage
|
||||
useThemeColor
|
||||
variant={
|
||||
localItem.Type === "Movie" && logoUrl
|
||||
? "Backdrop"
|
||||
@@ -357,7 +345,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item?.Overview} className="px-4 mb-4" />
|
||||
<OverviewText text={item?.Overview} className="px-4 my-4" />
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
|
||||
@@ -22,7 +22,6 @@ interface Props extends ImageProps {
|
||||
| "Thumb";
|
||||
quality?: number;
|
||||
width?: number;
|
||||
useThemeColor?: boolean;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
@@ -31,7 +30,6 @@ export const ItemImage: React.FC<Props> = ({
|
||||
variant = "Primary",
|
||||
quality = 90,
|
||||
width = 1000,
|
||||
useThemeColor = false,
|
||||
onError,
|
||||
...props
|
||||
}) => {
|
||||
|
||||
@@ -51,10 +51,24 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
`}
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className="w-44 mb-2">
|
||||
<View className="bg-neutral-800 h-24 w-full rounded-md mb-2"></View>
|
||||
<View className="bg-neutral-800 h-4 w-full rounded-md mb-2"></View>
|
||||
<View className="bg-neutral-800 h-4 w-1/2 rounded-md"></View>
|
||||
<View className="w-44" key={i}>
|
||||
<View className="bg-neutral-900 h-24 w-full rounded-md mb-1"></View>
|
||||
<View className="rounded-md overflow-hidden mb-1 self-start">
|
||||
<Text
|
||||
className="text-neutral-900 bg-neutral-900 rounded-md"
|
||||
numberOfLines={1}
|
||||
>
|
||||
Nisi mollit voluptate amet.
|
||||
</Text>
|
||||
</View>
|
||||
<View className="rounded-md overflow-hidden self-start mb-1">
|
||||
<Text
|
||||
className="text-neutral-900 bg-neutral-900 text-xs rounded-md "
|
||||
numberOfLines={1}
|
||||
>
|
||||
Lorem ipsum
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -198,11 +198,11 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
key={e.Id}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
<View className="flex flex-row items-center mb-2">
|
||||
<View className="w-32 aspect-video overflow-hidden mr-2">
|
||||
<View className="flex flex-row items-start mb-2">
|
||||
<View className="mr-2">
|
||||
<ContinueWatchingPoster
|
||||
size="small"
|
||||
item={e}
|
||||
width={128}
|
||||
useEpisodePoster
|
||||
/>
|
||||
</View>
|
||||
@@ -217,7 +217,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto">
|
||||
<View className="self-start ml-auto -mt-0.5">
|
||||
<DownloadItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -50,41 +50,32 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: optimizeServerStatistics } = useQuery({
|
||||
queryKey: ["optimize-server", settings?.optimizedVersionsServerUrl],
|
||||
queryFn: async () =>
|
||||
getStatistics({
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: await getOrSetDeviceId(),
|
||||
}),
|
||||
refetchInterval: 1000,
|
||||
staleTime: 0,
|
||||
enabled:
|
||||
!!settings?.optimizedVersionsServerUrl &&
|
||||
settings.optimizedVersionsServerUrl.length > 0,
|
||||
});
|
||||
|
||||
/********************
|
||||
* Background task
|
||||
*******************/
|
||||
useEffect(() => {
|
||||
checkStatusAsync();
|
||||
}, []);
|
||||
|
||||
const checkStatusAsync = async () => {
|
||||
await BackgroundFetch.getStatusAsync();
|
||||
await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoDownload) {
|
||||
registerBackgroundFetchAsync();
|
||||
} else {
|
||||
unregisterBackgroundFetchAsync();
|
||||
}
|
||||
(async () => {
|
||||
const registered = await checkStatusAsync();
|
||||
|
||||
checkStatusAsync();
|
||||
if (settings?.autoDownload === true && !registered) {
|
||||
registerBackgroundFetchAsync();
|
||||
toast.success("Background downlodas enabled");
|
||||
} else if (settings?.autoDownload === false && registered) {
|
||||
unregisterBackgroundFetchAsync();
|
||||
toast.info("Background downloads disabled");
|
||||
} else if (settings?.autoDownload === true && registered) {
|
||||
// Don't to anything
|
||||
} else if (settings?.autoDownload === false && !registered) {
|
||||
// Don't to anything
|
||||
} else {
|
||||
updateSettings({ autoDownload: false });
|
||||
}
|
||||
})();
|
||||
}, [settings?.autoDownload]);
|
||||
/**********************
|
||||
*********************/
|
||||
@@ -593,14 +584,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
<Text className="font-semibold">
|
||||
Optimized versions server
|
||||
</Text>
|
||||
<View
|
||||
className={`
|
||||
w-3 h-3 rounded-full
|
||||
${
|
||||
optimizeServerStatistics ? "bg-green-600" : "bg-red-600"
|
||||
}
|
||||
`}
|
||||
></View>
|
||||
</View>
|
||||
<Text className="text-xs opacity-50">
|
||||
Set the URL for the optimized versions server for downloads.
|
||||
@@ -620,8 +603,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
<Button
|
||||
color="purple"
|
||||
className="h-12 mt-2"
|
||||
onPress={() => {
|
||||
toast.info("Saved");
|
||||
onPress={async () => {
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl:
|
||||
optimizedVersionsServerUrl.length === 0
|
||||
@@ -630,6 +612,14 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
? optimizedVersionsServerUrl
|
||||
: optimizedVersionsServerUrl + "/",
|
||||
});
|
||||
const res = await getStatistics({
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: await getOrSetDeviceId(),
|
||||
});
|
||||
if (res) {
|
||||
toast.success("Connected");
|
||||
} else toast.error("Could not connect");
|
||||
}}
|
||||
>
|
||||
Save
|
||||
|
||||
@@ -1,46 +1,95 @@
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
isCloseToBlack,
|
||||
itemThemeColorAtom,
|
||||
} from "@/utils/atoms/primaryColor";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
|
||||
export const useImageColors = (
|
||||
uri: string | undefined | null,
|
||||
disabled = false
|
||||
) => {
|
||||
/**
|
||||
* Custom hook to extract and manage image colors for a given item.
|
||||
*
|
||||
* @param item - The BaseItemDto object representing the item.
|
||||
* @param disabled - A boolean flag to disable color extraction.
|
||||
*
|
||||
*/
|
||||
export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api || !item) return;
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
if (uri) {
|
||||
getColors(uri, {
|
||||
if (source?.uri) {
|
||||
// Check if colors are already cached in storage
|
||||
const _primary = storage.getString(`${source.uri}-primary`);
|
||||
const _text = storage.getString(`${source.uri}-text`);
|
||||
|
||||
// If colors are cached, use them and exit
|
||||
if (_primary && _text) {
|
||||
console.info("[useImageColors] Using cached colors for performance.");
|
||||
setPrimaryColor({
|
||||
primary: _primary,
|
||||
text: _text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: true,
|
||||
key: uri,
|
||||
key: source.uri,
|
||||
})
|
||||
.then((colors) => {
|
||||
let primary: string = "#fff";
|
||||
let average: string = "#fff";
|
||||
let secondary: string = "#fff";
|
||||
let text: string = "#000";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
average = colors.average;
|
||||
secondary = colors.muted;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.primary;
|
||||
secondary = colors.secondary;
|
||||
average = colors.background;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
secondary,
|
||||
average,
|
||||
text,
|
||||
});
|
||||
|
||||
// Cache the colors in storage
|
||||
if (source.uri && primary) {
|
||||
storage.set(`${source.uri}-primary`, primary);
|
||||
storage.set(`${source.uri}-text`, text);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [uri, setPrimaryColor, disabled]);
|
||||
}, [source?.uri, setPrimaryColor, disabled]);
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-mmkv": "^3.0.2",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
|
||||
@@ -2,12 +2,10 @@ import { atom, useAtom } from "jotai";
|
||||
|
||||
interface ThemeColors {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
average: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const calculateTextColor = (backgroundColor: string): string => {
|
||||
export 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);
|
||||
@@ -48,7 +46,7 @@ const calculateRelativeLuminance = (rgb: number[]): number => {
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
|
||||
const isCloseToBlack = (color: string): boolean => {
|
||||
export const isCloseToBlack = (color: string): boolean => {
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
@@ -57,33 +55,13 @@ const isCloseToBlack = (color: string): boolean => {
|
||||
return r < 20 && g < 20 && b < 20;
|
||||
};
|
||||
|
||||
const adjustToNearBlack = (color: string): string => {
|
||||
export const adjustToNearBlack = (color: string): string => {
|
||||
return "#212121"; // A very dark gray, almost black
|
||||
};
|
||||
|
||||
const baseThemeColorAtom = atom<ThemeColors>({
|
||||
export const itemThemeColorAtom = 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);
|
||||
let newColors = { ...currentColors, ...update };
|
||||
|
||||
// Adjust primary color if it's too close to black
|
||||
if (newColors.primary && isCloseToBlack(newColors.primary)) {
|
||||
newColors.primary = adjustToNearBlack(newColors.primary);
|
||||
}
|
||||
|
||||
// Recalculate text color if primary color changes
|
||||
if (update.primary) newColors.text = calculateTextColor(newColors.primary);
|
||||
|
||||
set(baseThemeColorAtom, newColors);
|
||||
}
|
||||
);
|
||||
|
||||
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);
|
||||
|
||||
3
utils/mmkv.ts
Normal file
3
utils/mmkv.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
export const storage = new MMKV();
|
||||
Reference in New Issue
Block a user