From 0d07f7216c46a61ffe26c569e587ed71ee0a3174 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 22:32:09 +0200 Subject: [PATCH] fix: design --- components/AudioTrackSelector.tsx | 2 +- components/BitrateSelector.tsx | 2 +- components/Chromecast.tsx | 13 +- components/DownloadItem.tsx | 120 ++++++++----------- components/ItemContent.tsx | 68 ++++++----- components/ItemHeader.tsx | 4 +- components/MediaSourceSelector.tsx | 6 +- components/ParallaxPage.tsx | 40 +++++-- components/PlayButton.tsx | 21 +++- components/PlayedStatus.tsx | 17 ++- components/Ratings.tsx | 4 +- components/SubtitleTrackSelector.tsx | 4 +- components/common/HeaderBackButton.tsx | 2 +- components/common/ItemImage.tsx | 11 +- components/movies/MoviesTitleHeader.tsx | 6 +- components/series/EpisodeTitleHeader.tsx | 22 ++-- components/series/SeasonEpisodesCarousel.tsx | 4 +- components/stacks/NestedTabPageStack.tsx | 1 - hooks/useImageColors.ts | 42 +++++++ utils/atoms/primaryColor.ts | 73 +++++++++++ utils/jellyfin/media/getStreamUrl.ts | 1 - utils/jellyfin/session/capabilities.ts | 2 +- 22 files changed, 303 insertions(+), 162 deletions(-) create mode 100644 hooks/useImageColors.ts create mode 100644 utils/atoms/primaryColor.ts diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 1d01c220..15d73f0a 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -42,7 +42,7 @@ export const AudioTrackSelector: React.FC = ({ - Audio streams + Audio diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 45370614..9379e7c9 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -55,7 +55,7 @@ export const BitrateSelector: React.FC = ({ - Bitrate + Quality diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 6539ce0b..f3ff8f16 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -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 = ({ width = 48, height = 48, background = "transparent", + ...props }) => { const client = useRemoteMediaClient(); const castDevice = useCastDevice(); @@ -37,7 +38,10 @@ export const Chromecast: React.FC = ({ if (background === "transparent") return ( - + ); @@ -46,6 +50,7 @@ export const Chromecast: React.FC = ({ diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 5f83ad03..d2ff0893 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -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 = ({ item, ...props }) => { enabled: !!item.Id, }); - if (isFetching) { - return ( - + return ( + + {isFetching ? ( - - ); - } - - if (process && process?.item.Id === item.Id) { - return ( - { - router.push("/downloads"); - }} - {...props} - > - + ) : process && process?.item.Id === item.Id ? ( + { + router.push("/downloads"); + }} + > {process.progress === 0 ? ( ) : ( @@ -173,61 +169,41 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { /> )} - - - ); - } - - if (queue.some((i) => i.id === item.Id)) { - return ( - { - router.push("/downloads"); - }} - {...props} - > - + + ) : queue.some((i) => i.id === item.Id) ? ( + { + router.push("/downloads"); + }} + > - - - ); - } - - if (downloaded) { - return ( - { - router.push("/downloads"); - }} - {...props} - > - + + ) : downloaded ? ( + { + router.push("/downloads"); + }} + > - - - ); - } else { - return ( - { - 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} - > - - - - - ); - } + + ) : ( + { + 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, + }); + }} + > + + + )} + + ); }; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 1cc311fa..f64e17a2 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -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(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 && ( + + + + + + ), + }); + }, [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 ( {item ? ( = React.memo(({ id }) => { } > - - - + + + {item ? ( - - - - - ) : ( - - - - )} - - {item ? ( - + setMaxBitrate(val)} selected={maxBitrate} @@ -227,7 +241,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { )} - + {item?.Type === "Episode" && ( diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx index d4c204b1..1eb15d64 100644 --- a/components/ItemHeader.tsx +++ b/components/ItemHeader.tsx @@ -21,10 +21,10 @@ export const ItemHeader: React.FC = ({ item, ...props }) => { ); return ( - + + {item.Type === "Episode" && } {item.Type === "Movie" && } - ); }; diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index c2a95c23..44a3766a 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -34,14 +34,14 @@ export const MediaSourceSelector: React.FC = ({ useEffect(() => { if (mediaSources?.length) onChange(mediaSources[0]); - }, []); + }, [mediaSources]); return ( - Video streams + Video {tc(selectedMediaSource, 7)} @@ -58,7 +58,7 @@ export const MediaSourceSelector: React.FC = ({ collisionPadding={8} sideOffset={8} > - Video streams + Media sources {mediaSources?.map((source, idx: number) => ( = ({ {logo && ( = ({ )} - {episodePoster && ( - - - {episodePoster} - - - )} - = ({ {headerImage} - + + + {children} diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 5ced44c6..642aff8a 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -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 { item?: BaseItemDto | null; @@ -22,6 +24,8 @@ export const PlayButton: React.FC = ({ 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 = ({ 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" > - + {runtimeTicksToMinutes(item?.RunTimeTicks)} - - {client && } + + {client && } diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index 1cb65034..b3b55ee9 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -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 = ({ item, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -37,7 +41,10 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { }; return ( - + {item.UserData?.Played ? ( { @@ -51,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { }} > - + ) : ( @@ -67,7 +74,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { }} > - + )} diff --git a/components/Ratings.tsx b/components/Ratings.tsx index 65834fdf..51b6be3b 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -8,10 +8,10 @@ interface Props extends ViewProps { item?: BaseItemDto | null; } -export const Ratings: React.FC = ({ item }) => { +export const Ratings: React.FC = ({ item, ...props }) => { if (!item) return null; return ( - + {item.OfficialRating && ( )} diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 8141e312..c70ad7dc 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -48,7 +48,7 @@ export const SubtitleTrackSelector: React.FC = ({ - Subtitles + Subtitle @@ -69,7 +69,7 @@ export const SubtitleTrackSelector: React.FC = ({ collisionPadding={8} sideOffset={8} > - Subtitles + Subtitle tracks { diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx index 4ff4c38d..86ed3483 100644 --- a/components/common/HeaderBackButton.tsx +++ b/components/common/HeaderBackButton.tsx @@ -36,7 +36,7 @@ export const HeaderBackButton: React.FC = ({ className="drop-shadow-2xl" name="arrow-back" size={24} - color="#077DF2" + color="white" /> diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index f312a16c..8d7c6461 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -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 = ({ return src; }, [item.ImageTags]); + useImageColors(source?.uri); + return ( = ({ item, ...props }) => { return ( - - {item?.Name} - {item?.ProductionYear} + + {item?.Name} + {item?.ProductionYear} ); }; diff --git a/components/series/EpisodeTitleHeader.tsx b/components/series/EpisodeTitleHeader.tsx index 41c48ffe..e0488728 100644 --- a/components/series/EpisodeTitleHeader.tsx +++ b/components/series/EpisodeTitleHeader.tsx @@ -11,14 +11,9 @@ export const EpisodeTitleHeader: React.FC = ({ item, ...props }) => { const router = useRouter(); return ( - - router.push(`/(auth)/series/${item.SeriesId}`)} - > - {item?.SeriesName} - - {item?.Name} - + + {item?.Name} + { router.push( @@ -27,14 +22,13 @@ export const EpisodeTitleHeader: React.FC = ({ item, ...props }) => { ); }} > - {item?.SeasonName} + {item?.SeasonName} - {"—"} - - {`Episode ${item.IndexNumber}`} - + + {"—"} + {`Episode ${item.IndexNumber}`} - {item?.ProductionYear} + {item?.ProductionYear} ); }; diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index d9a33304..43bd6780 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -107,14 +107,12 @@ export const SeasonEpisodesCarousel: React.FC = ({ }, [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]); diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 18f0365b..930c4bfc 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -8,7 +8,6 @@ const commonScreenOptions = { headerTransparent: true, headerShadowVisible: false, headerLeft: () => , - headerRight: () => , }; const routes = [ diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts new file mode 100644 index 00000000..7d93e8ce --- /dev/null +++ b/hooks/useImageColors.ts @@ -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]); +}; diff --git a/utils/atoms/primaryColor.ts b/utils/atoms/primaryColor.ts new file mode 100644 index 00000000..d7036163 --- /dev/null +++ b/utils/atoms/primaryColor.ts @@ -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({ + primary: "#FFFFFF", + secondary: "#000000", + average: "#888888", + text: "#000000", +}); + +export const itemThemeColorAtom = atom( + (get) => get(baseThemeColorAtom), + (get, set, update: Partial) => { + 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); diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index caf82a46..1ca210a7 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -34,7 +34,6 @@ export const getStreamUrl = async ({ mediaSourceId?: string | null; }) => { if (!api || !userId || !item?.Id || !mediaSourceId) { - console.error("Missing required parameters"); return null; } diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index 0c47d869..ccb068c2 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -25,7 +25,7 @@ export const postCapabilities = async ({ sessionId, }: PostCapabilitiesParams): Promise => { if (!api || !itemId || !sessionId) { - throw new Error("Missing required parameters"); + throw new Error("Missing parameters for marking item as not played"); } try {