From aa785b0f952ea6913f52f7731d3c88a526ca0c6e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 4 Jan 2025 17:08:00 +0100 Subject: [PATCH] chore: remove deps --- .../jellyseerr/page.tsx | 145 +++++---- app/(auth)/(tabs)/(libraries)/_layout.tsx | 306 ++++++++++-------- app/(auth)/(tabs)/_layout.tsx | 12 - components/AudioTrackSelector.tsx | 120 ++++--- components/BitrateSelector.tsx | 118 ++++--- components/ItemContent.tsx | 2 - components/MediaSourceSelector.tsx | 123 ++++--- components/SubtitleTrackSelector.tsx | 149 +++++---- components/common/ItemImage.tsx | 1 - components/series/SeasonDropdown.tsx | 98 ++++-- components/series/SeasonPicker.tsx | 1 - components/settings/AudioToggles.tsx | 137 +++++--- components/settings/SettingToggles.tsx | 117 ++++--- components/settings/SubtitleToggles.tsx | 239 +++++++++----- .../video-player/controls/EpisodeList.tsx | 1 - .../controls/dropdown/DropdownViewDirect.tsx | 225 ++++++++----- .../dropdown/DropdownViewTranscoding.tsx | 255 +++++++++------ hooks/useImageColors.ts | 108 ------- 18 files changed, 1248 insertions(+), 909 deletions(-) delete mode 100644 hooks/useImageColors.ts diff --git a/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx index edf91697..bac9f0f5 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx @@ -1,33 +1,32 @@ -import React, { useCallback, useRef, useState } from "react"; -import { useLocalSearchParams } from "expo-router"; -import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; -import { Text } from "@/components/common/Text"; -import { ParallaxScrollView } from "@/components/ParallaxPage"; -import { Image } from "expo-image"; -import { TouchableOpacity, View } from "react-native"; -import { Ionicons } from "@expo/vector-icons"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { OverviewText } from "@/components/OverviewText"; -import { GenreTags } from "@/components/GenreTags"; -import { MediaType } from "@/utils/jellyseerr/server/constants/media"; -import { useQuery } from "@tanstack/react-query"; -import { useJellyseerr } from "@/hooks/useJellyseerr"; import { Button } from "@/components/Button"; +import { Input } from "@/components/common/Input"; +import { Text } from "@/components/common/Text"; +import { GenreTags } from "@/components/GenreTags"; +import { OverviewText } from "@/components/OverviewText"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { JellyserrRatings } from "@/components/Ratings"; +import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; +import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { + IssueType, + IssueTypeName, +} from "@/utils/jellyseerr/server/constants/issue"; +import { MediaType } from "@/utils/jellyseerr/server/constants/media"; +import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; +import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; +import { Ionicons } from "@expo/vector-icons"; import { BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView, } from "@gorhom/bottom-sheet"; -import { - IssueType, - IssueTypeName, -} from "@/utils/jellyseerr/server/constants/issue"; -import * as DropdownMenu from "zeego/dropdown-menu"; -import { Input } from "@/components/common/Input"; -import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; -import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; -import { JellyserrRatings } from "@/components/Ratings"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useLocalSearchParams } from "expo-router"; +import React, { useCallback, useRef, useState } from "react"; +import { Modal, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; const Page: React.FC = () => { const insets = useSafeAreaInsets(); @@ -51,6 +50,7 @@ const Page: React.FC = () => { const [issueType, setIssueType] = useState(); const [issueMessage, setIssueMessage] = useState(); const bottomSheetModalRef = useRef(null); + const [isIssueTypeModalVisible, setIsIssueTypeModalVisible] = useState(false); const { data: details, @@ -231,47 +231,68 @@ const Page: React.FC = () => { - - - - - Issue Type - - - - {issueType - ? IssueTypeName[issueType] - : "Select an issue"} - - - - - + Issue Type + setIsIssueTypeModalVisible(true)} > - Types - {Object.entries(IssueTypeName) - .reverse() - .map(([key, value], idx) => ( - - setIssueType(key as unknown as IssueType) - } + + {issueType ? IssueTypeName[issueType] : "Select an issue"} + + + + + setIsIssueTypeModalVisible(false)} + > + setIsIssueTypeModalVisible(false)} + > + + + + Select Issue Type + + + + {Object.entries(IssueTypeName) + .reverse() + .map(([key, value]) => ( + { + setIssueType(key as unknown as IssueType); + setIsIssueTypeModalVisible(false); + }} + > + {value} + + ))} + + setIsIssueTypeModalVisible(false)} > - - {value} - - - ))} - - + + Cancel + + + + + + (null); + + const MenuItem = ({ + label, + selected, + onPress, + disabled = false, + }: { + label: string; + selected?: boolean; + onPress: () => void; + disabled?: boolean; + }) => ( + + {label} + {selected && } + + ); + + const MenuSection = ({ title }: { title: string }) => ( + + {title} + + ); if (!settings?.libraryOptions) return null; @@ -22,163 +54,167 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios" ? true : false, headerShadowVisible: false, headerRight: () => ( - - - - - { + setIsMenuVisible(false); + setActiveSubmenu(null); + }} + > + { + setIsMenuVisible(false); + setActiveSubmenu(null); + }} > - Display - - - - Display - - - + + {!activeSubmenu ? ( + <> + + setActiveSubmenu("display")} + /> + setActiveSubmenu("imageStyle")} + /> + { + if (settings.libraryOptions.imageStyle === "poster") + return; + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showTitles: !settings.libraryOptions.showTitles, + }, + }); + }} + /> + { + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showStats: !settings.libraryOptions.showStats, + }, + }); + }} + /> + + ) : activeSubmenu === "display" ? ( + <> + + setActiveSubmenu(null)} + > + + + Display + + { updateSettings({ libraryOptions: { ...settings.libraryOptions, display: "row", }, - }) - } - > - - - Row - - - + }); + setActiveSubmenu(null); + }} + /> + { updateSettings({ libraryOptions: { ...settings.libraryOptions, display: "list", }, - }) + }); + setActiveSubmenu(null); + }} + /> + + ) : activeSubmenu === "imageStyle" ? ( + <> + + setActiveSubmenu(null)} + > + + + + Image Style + + + - - - List - - - - - - - Image style - - - + onPress={() => { updateSettings({ libraryOptions: { ...settings.libraryOptions, imageStyle: "poster", }, - }) + }); + setActiveSubmenu(null); + }} + /> + - - - Poster - - - + onPress={() => { updateSettings({ libraryOptions: { ...settings.libraryOptions, imageStyle: "cover", }, - }) - } - > - - - Cover - - - - - - - { - if (settings.libraryOptions.imageStyle === "poster") - return; - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showTitles: newValue === "on" ? true : false, - }, - }); - }} - > - - - Show titles - - - { - updateSettings({ - libraryOptions: { - ...settings.libraryOptions, - showStats: newValue === "on" ? true : false, - }, - }); - }} - > - - - Show stats - - - + }); + setActiveSubmenu(null); + }} + /> + + ) : null} - - - + { + setIsMenuVisible(false); + setActiveSubmenu(null); + }} + > + Done + + + + ), }} /> diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index f256ab50..c9cce89e 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -73,18 +73,6 @@ export default function TabLayout() { : () => ({ sfSymbol: "rectangle.stack" }), }} /> - require("@/assets/icons/list.png") - : () => ({ sfSymbol: "list.dash" }), - }} - /> ); diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 75fd659c..7dfbd902 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -1,7 +1,7 @@ +import { Ionicons } from "@expo/vector-icons"; import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { useMemo, useState } from "react"; +import { Modal, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; interface Props extends React.ComponentProps { @@ -16,6 +16,8 @@ export const AudioTrackSelector: React.FC = ({ selected, ...props }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const audioStreams = useMemo( () => source?.MediaStreams?.filter((x) => x.Type === "Audio"), [source] @@ -25,50 +27,80 @@ export const AudioTrackSelector: React.FC = ({ () => audioStreams?.find((x) => x.Index === selected), [audioStreams, selected] ); - return ( - - - - - Audio - - - {selectedAudioSteam?.DisplayTitle} + <> + + + Audio + setIsModalVisible(true)} + > + + {selectedAudioSteam?.DisplayTitle} + + + + + + + setIsModalVisible(false)} + > + setIsModalVisible(false)} + > + + + + Audio Streams + + + + {audioStreams?.map((audio, idx: number) => ( + { + if (audio.Index !== null && audio.Index !== undefined) { + onChange(audio.Index); + setIsModalVisible(false); + } + }} + > + {audio.DisplayTitle} + {audio.Index === selected && ( + + )} + + ))} + + + setIsModalVisible(false)} + > + Cancel - - - Audio streams - {audioStreams?.map((audio, idx: number) => ( - { - if (audio.Index !== null && audio.Index !== undefined) - onChange(audio.Index); - }} - > - - {audio.DisplayTitle} - - - ))} - - - + + + ); }; diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 0f1bd28b..778ff1ed 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -1,7 +1,7 @@ -import { TouchableOpacity, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { TouchableOpacity, View, Modal } from "react-native"; import { Text } from "./common/Text"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { Ionicons } from "@expo/vector-icons"; export type Bitrate = { key: string; @@ -49,6 +49,8 @@ export const BitrateSelector: React.FC = ({ inverted, ...props }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const sorted = useMemo(() => { if (inverted) return BITRATES.sort( @@ -57,49 +59,81 @@ export const BitrateSelector: React.FC = ({ return BITRATES.sort( (a, b) => (b.value || Infinity) - (a.value || Infinity) ); - }, []); + }, [inverted]); return ( - - - - - Quality - - - {BITRATES.find((b) => b.value === selected?.value)?.key} + <> + + + Quality + setIsModalVisible(true)} + > + + {BITRATES.find((b) => b.value === selected?.value)?.key} + + + + + + + setIsModalVisible(false)} + > + setIsModalVisible(false)} + > + + + + Select Quality + + + + {sorted.map((bitrate) => ( + { + onChange(bitrate); + setIsModalVisible(false); + }} + > + {bitrate.key} + {bitrate.value === selected?.value && ( + + )} + + ))} + + + setIsModalVisible(false)} + > + Cancel - - - Bitrates - {sorted.map((b) => ( - { - onChange(b); - }} - > - {b.key} - - ))} - - - + + + ); }; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index e4c5e820..e819ad42 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -11,7 +11,6 @@ import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; -import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom } from "@/providers/JellyfinProvider"; import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { useSettings } from "@/utils/atoms/settings"; @@ -44,7 +43,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( const [settings] = useSettings(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - useImageColors({ item }); const [loadingLogo, setLoadingLogo] = useState(true); const [headerHeight, setHeaderHeight] = useState(350); diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 34f02fd9..6ad63144 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -1,13 +1,12 @@ -import { tc } from "@/utils/textTools"; +import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb"; +import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { useEffect, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { useMemo, useState } from "react"; +import { Modal, TouchableOpacity, View } from "react-native"; import { Text } from "./common/Text"; -import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb"; interface Props extends React.ComponentProps { item: BaseItemDto; @@ -21,6 +20,8 @@ export const MediaSourceSelector: React.FC = ({ selected, ...props }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const selectedName = useMemo( () => item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( @@ -30,48 +31,80 @@ export const MediaSourceSelector: React.FC = ({ ); return ( - - - - - Video - - {selectedName} + <> + + + Video + setIsModalVisible(true)} + > + {selectedName} + + + + + + setIsModalVisible(false)} + > + setIsModalVisible(false)} + > + + + + Media Sources + + + + + {item.MediaSources?.map((source, idx: number) => ( + { + onChange(source); + setIsModalVisible(false); + }} + > + + {`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits( + source.Size + )}`} + + {source.Id === selected?.Id && ( + + )} + + ))} + + + setIsModalVisible(false)} + > + Cancel - - - Media sources - {item.MediaSources?.map((source, idx: number) => ( - { - onChange(source); - }} - > - - {`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits( - source.Size - )}`} - - - ))} - - - + + + ); }; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 087363a3..c2742929 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -1,10 +1,10 @@ import { tc } from "@/utils/textTools"; import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo } from "react"; -import { Platform, TouchableOpacity, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { useMemo, useState } from "react"; +import { Platform, TouchableOpacity, View, Modal } from "react-native"; import { Text } from "./common/Text"; import { SubtitleHelper } from "@/utils/SubtitleHelper"; +import { Ionicons } from "@expo/vector-icons"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; @@ -20,6 +20,8 @@ export const SubtitleTrackSelector: React.FC = ({ isTranscoding, ...props }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const subtitleStreams = useMemo(() => { const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []); @@ -38,59 +40,98 @@ export const SubtitleTrackSelector: React.FC = ({ if (subtitleStreams.length === 0) return null; return ( - - - - - Subtitle - - - {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : "None"} + <> + + + Subtitle + setIsModalVisible(true)} + > + + {selectedSubtitleSteam + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) + : "None"} + + + + + + + setIsModalVisible(false)} + > + setIsModalVisible(false)} + > + + + + Subtitle Tracks + + + + { + onChange(-1); + setIsModalVisible(false); + }} + > + None + {selected === -1 && ( + + )} + + + {subtitleStreams?.map((subtitle, idx: number) => ( + { + if ( + subtitle.Index !== undefined && + subtitle.Index !== null + ) { + onChange(subtitle.Index); + setIsModalVisible(false); + } + }} + > + {subtitle.DisplayTitle} + {subtitle.Index === selected && ( + + )} + + ))} + + + setIsModalVisible(false)} + > + Cancel - - - Subtitle tracks - { - onChange(-1); - }} - > - None - - {subtitleStreams?.map((subtitle, idx: number) => ( - { - if (subtitle.Index !== undefined && subtitle.Index !== null) - onChange(subtitle.Index); - }} - > - - {subtitle.DisplayTitle} - - - ))} - - - + + + ); }; diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index 9e38bc06..7685b279 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -1,4 +1,3 @@ -import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getItemImage } from "@/utils/getItemImage"; import { Ionicons } from "@expo/vector-icons"; diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index 5c333f2e..4e5f02ac 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -1,8 +1,8 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useEffect, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { useEffect, useMemo, useState } from "react"; +import { TouchableOpacity, View, Modal } from "react-native"; import { Text } from "../common/Text"; +import { Ionicons } from "@expo/vector-icons"; type Props = { item: BaseItemDto; @@ -29,6 +29,8 @@ export const SeasonDropdown: React.FC = ({ state, onSelect, }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const keys = useMemo( () => item.Type === "Episode" @@ -55,7 +57,6 @@ export const SeasonDropdown: React.FC = ({ let initialIndex: number | undefined; if (initialSeasonIndex !== undefined) { - // Use the provided initialSeasonIndex if it exists in the seasons const seasonExists = seasons.some( (season: any) => season[keys.index] === initialSeasonIndex ); @@ -65,7 +66,6 @@ export const SeasonDropdown: React.FC = ({ } if (initialIndex === undefined) { - // Fall back to the previous logic if initialIndex is not set const season1 = seasons.find((season: any) => season[keys.index] === 1); const season0 = seasons.find((season: any) => season[keys.index] === 0); const firstSeason = season1 || season0 || seasons[0]; @@ -87,35 +87,65 @@ export const SeasonDropdown: React.FC = ({ Number(a[keys.index]) - Number(b[keys.index]); return ( - - - - - Season {seasonIndex} - - - - + setIsModalVisible(true)} > - Seasons - {seasons?.sort(sortByIndex).map((season: any) => ( - onSelect(season)} - > - - {season[keys.title]} - - - ))} - - + Season {seasonIndex} + + + + setIsModalVisible(false)} + > + setIsModalVisible(false)} + > + + + + Select Season + + + + + {seasons?.sort(sortByIndex).map((season: any) => ( + { + onSelect(season); + setIsModalVisible(false); + }} + > + {season[keys.title]} + {Number(season[keys.index]) === Number(seasonIndex) && ( + + )} + + ))} + + + setIsModalVisible(false)} + > + Cancel + + + + + ); }; diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index c64bdf4b..0c484624 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -5,7 +5,6 @@ import { import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { runtimeTicksToSeconds } from "@/utils/time"; -import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index ec9d71ce..390d27f2 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -1,8 +1,9 @@ -import { TouchableOpacity, View, ViewProps } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { TouchableOpacity, View, ViewProps, Modal } from "react-native"; import { Text } from "../common/Text"; import { useMedia } from "./MediaContext"; import { Switch } from "react-native-gesture-handler"; +import { Ionicons } from "@expo/vector-icons"; +import { useState } from "react"; interface Props extends ViewProps {} @@ -10,69 +11,35 @@ export const AudioToggles: React.FC = ({ ...props }) => { const media = useMedia(); const { settings, updateSettings } = media; const cultures = media.cultures; + const [isModalVisible, setIsModalVisible] = useState(false); if (!settings) return null; return ( Audio - - + + Audio language Choose a default audio language. - - - - - {settings?.defaultAudioLanguage?.DisplayName || "None"} - - - - - Languages - { - updateSettings({ - defaultAudioLanguage: null, - }); - }} - > - None - - {cultures?.map((l) => ( - { - updateSettings({ - defaultAudioLanguage: l, - }); - }} - > - - {l.DisplayName} - - - ))} - - + setIsModalVisible(true)} + > + {settings?.defaultAudioLanguage?.DisplayName || "None"} + + + @@ -89,6 +56,7 @@ export const AudioToggles: React.FC = ({ ...props }) => { /> + @@ -109,6 +77,71 @@ export const AudioToggles: React.FC = ({ ...props }) => { + + setIsModalVisible(false)} + > + setIsModalVisible(false)} + > + + + + Select Language + + + + + { + updateSettings({ + defaultAudioLanguage: null, + }); + setIsModalVisible(false); + }} + > + None + {!settings?.defaultAudioLanguage && ( + + )} + + + {cultures?.map((l) => ( + { + updateSettings({ + defaultAudioLanguage: l, + }); + setIsModalVisible(false); + }} + > + {l.DisplayName} + {settings?.defaultAudioLanguage + ?.ThreeLetterISOLanguageName === + l.ThreeLetterISOLanguageName && ( + + )} + + ))} + + + setIsModalVisible(false)} + > + Cancel + + + + ); }; diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index d828f6d9..eeadfb6d 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -1,17 +1,18 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; import React, { useState } from "react"; import { Linking, + Modal, Switch, TouchableOpacity, View, ViewProps, } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { Button } from "../Button"; import { Input } from "../common/Input"; import { Text } from "../common/Text"; @@ -21,7 +22,6 @@ import { JellyseerrSettings } from "./Jellyseerr"; import { MediaProvider } from "./MediaContext"; import { MediaToggles } from "./MediaToggles"; import { SubtitleToggles } from "./SubtitleToggles"; - interface Props extends ViewProps {} export const SettingToggles: React.FC = ({ ...props }) => { @@ -31,6 +31,8 @@ export const SettingToggles: React.FC = ({ ...props }) => { const [user] = useAtom(userAtom); const [marlinUrl, setMarlinUrl] = useState(""); const queryClient = useQueryClient(); + const [isSearchEngineModalVisible, setIsSearchEngineModalVisible] = + useState(false); const { data: mediaListCollections, @@ -54,6 +56,13 @@ export const SettingToggles: React.FC = ({ ...props }) => { staleTime: 0, }); + type SearchEngine = "Jellyfin" | "Marlin"; + + const searchEngines: Array<{ id: SearchEngine; name: string }> = [ + { id: "Jellyfin", name: "Jellyfin" }, + { id: "Marlin", name: "Marlin" }, + ]; + if (!settings) return null; return ( @@ -183,54 +192,27 @@ export const SettingToggles: React.FC = ({ ...props }) => { - + Search engine Choose the search engine you want to use. - - - - {settings.searchEngine} - - - - Profiles - { - updateSettings({ searchEngine: "Jellyfin" }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - Jellyfin - - { - updateSettings({ searchEngine: "Marlin" }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - Marlin - - - + setIsSearchEngineModalVisible(true)} + > + {settings.searchEngine} + + + {settings.searchEngine === "Marlin" && ( @@ -269,6 +251,55 @@ export const SettingToggles: React.FC = ({ ...props }) => { )} + + setIsSearchEngineModalVisible(false)} + > + setIsSearchEngineModalVisible(false)} + > + + + + Select Search Engine + + + + + {searchEngines.map((engine) => ( + { + updateSettings({ + searchEngine: engine.id, + }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + setIsSearchEngineModalVisible(false); + }} + > + {engine.name} + {settings.searchEngine === engine.id && ( + + )} + + ))} + + + setIsSearchEngineModalVisible(false)} + > + Cancel + + + + diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 93745df2..c554460f 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -1,9 +1,10 @@ -import { TouchableOpacity, View, ViewProps } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { Ionicons } from "@expo/vector-icons"; +import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import { useState } from "react"; +import { Modal, TouchableOpacity, View, ViewProps } from "react-native"; +import { Switch } from "react-native-gesture-handler"; import { Text } from "../common/Text"; import { useMedia } from "./MediaContext"; -import { Switch } from "react-native-gesture-handler"; -import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; interface Props extends ViewProps {} @@ -11,6 +12,9 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const media = useMedia(); const { settings, updateSettings } = media; const cultures = media.cultures; + const [isLanguageModalVisible, setIsLanguageModalVisible] = useState(false); + const [isModeModalVisible, setIsModeModalVisible] = useState(false); + if (!settings) return null; const subtitleModes = [ @@ -24,69 +28,31 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { return ( Subtitle - - + + Subtitle language Choose a default subtitle language. - - - - - {settings?.defaultSubtitleLanguage?.DisplayName || "None"} - - - - - Languages - { - updateSettings({ - defaultSubtitleLanguage: null, - }); - }} - > - None - - {cultures?.map((l) => ( - { - updateSettings({ - defaultSubtitleLanguage: l, - }); - }} - > - - {l.DisplayName} - - - ))} - - + setIsLanguageModalVisible(true)} + > + + {settings?.defaultSubtitleLanguage?.DisplayName || "None"} + + + - + Subtitle Mode @@ -95,36 +61,18 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { multiple options are available. - - - - {settings?.subtitleMode || "Loading"} - - - - Subtitle Mode - {subtitleModes?.map((l) => ( - { - updateSettings({ - subtitleMode: l, - }); - }} - > - {l} - - ))} - - + setIsModeModalVisible(true)} + > + {settings?.subtitleMode || "Loading"} + + @@ -186,6 +134,119 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { + setIsLanguageModalVisible(false)} + > + setIsLanguageModalVisible(false)} + > + + + + Select Language + + + + + { + updateSettings({ + defaultSubtitleLanguage: null, + }); + setIsLanguageModalVisible(false); + }} + > + None + {!settings?.defaultSubtitleLanguage && ( + + )} + + + {cultures?.map((l) => ( + { + updateSettings({ + defaultSubtitleLanguage: l, + }); + setIsLanguageModalVisible(false); + }} + > + {l.DisplayName} + {settings?.defaultSubtitleLanguage + ?.ThreeLetterISOLanguageName === + l.ThreeLetterISOLanguageName && ( + + )} + + ))} + + + setIsLanguageModalVisible(false)} + > + Cancel + + + + + + {/* Subtitle Mode Selection Modal */} + setIsModeModalVisible(false)} + > + setIsModeModalVisible(false)} + > + + + + Select Subtitle Mode + + + + + {subtitleModes?.map((mode) => ( + { + updateSettings({ + subtitleMode: mode, + }); + setIsModeModalVisible(false); + }} + > + {mode} + {settings?.subtitleMode === mode && ( + + )} + + ))} + + + setIsModeModalVisible(false)} + > + Cancel + + + + ); }; diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index 9fae9c1a..a980c310 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -19,7 +19,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; import { TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; type Props = { item: BaseItemDto; diff --git a/components/video-player/controls/dropdown/DropdownViewDirect.tsx b/components/video-player/controls/dropdown/DropdownViewDirect.tsx index 65ba3056..6a87428c 100644 --- a/components/video-player/controls/dropdown/DropdownViewDirect.tsx +++ b/components/video-player/controls/dropdown/DropdownViewDirect.tsx @@ -1,13 +1,13 @@ import React, { useMemo, useState } from "react"; -import { View, TouchableOpacity } from "react-native"; +import { View, TouchableOpacity, Modal } from "react-native"; import { Ionicons } from "@expo/vector-icons"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { useControlContext } from "../contexts/ControlContext"; import { useVideoContext } from "../contexts/VideoContext"; import { EmbeddedSubtitle, ExternalSubtitle } from "../types"; import { useAtomValue } from "jotai"; import { apiAtom } from "@/providers/JellyfinProvider"; import { router, useLocalSearchParams } from "expo-router"; +import { Text } from "@/components/common/Text"; interface DropdownViewDirectProps { showControls: boolean; @@ -16,6 +16,11 @@ interface DropdownViewDirectProps { const DropdownViewDirect: React.FC = ({ showControls, }) => { + const [isMainModalVisible, setIsMainModalVisible] = useState(false); + const [activeSubMenu, setActiveSubMenu] = useState< + "subtitle" | "audio" | null + >(null); + const api = useAtomValue(apiAtom); const ControlContext = useControlContext(); const mediaSource = ControlContext?.mediaSource; @@ -51,12 +56,10 @@ const DropdownViewDirect: React.FC = ({ deliveryUrl: s.DeliveryUrl, })) || []; - // Combine embedded subs with external subs only if not offline return [...embeddedSubs, ...externalSubs] as ( | EmbeddedSubtitle | ExternalSubtitle )[]; - return embeddedSubs as EmbeddedSubtitle[]; }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]); const { subtitleIndex, audioIndex } = useLocalSearchParams<{ @@ -67,87 +70,143 @@ const DropdownViewDirect: React.FC = ({ bitrateValue: string; }>(); + const closeAllModals = () => { + setIsMainModalVisible(false); + setActiveSubMenu(null); + }; + + const MenuOption = ({ + label, + onPress, + }: { + label: string; + onPress: () => void; + }) => ( + + {label} + + + ); + return ( - - - - - - - + setIsMainModalVisible(true)} > - - - Subtitle - - - {allSubtitleTracksForDirectPlay?.map((sub, idx: number) => ( - { - if ("deliveryUrl" in sub && sub.deliveryUrl) { - setSubtitleURL && - setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name); - } else { - setSubtitleTrack && setSubtitleTrack(sub.index); - } - router.setParams({ - subtitleIndex: sub.index.toString(), - }); - }} - > - - {sub.name} - - - ))} - - - - - Audio - - - {audioTracks?.map((track, idx: number) => ( - { - setAudioTrack && setAudioTrack(track.index); - router.setParams({ - audioIndex: track.index.toString(), - }); - }} - > - - {track.name} - - - ))} - - - - + + + + + + + {!activeSubMenu ? ( + <> + + + Settings + + + + setActiveSubMenu("subtitle")} + /> + setActiveSubMenu("audio")} + /> + + + ) : activeSubMenu === "subtitle" ? ( + <> + + setActiveSubMenu(null)}> + + + Subtitle + + + {allSubtitleTracksForDirectPlay?.map((sub, idx) => ( + { + if ("deliveryUrl" in sub && sub.deliveryUrl) { + setSubtitleURL?.( + api?.basePath + sub.deliveryUrl, + sub.name + ); + } else { + setSubtitleTrack?.(sub.index); + } + router.setParams({ + subtitleIndex: sub.index.toString(), + }); + closeAllModals(); + }} + > + {sub.name} + {subtitleIndex === sub.index.toString() && ( + + )} + + ))} + + + ) : ( + <> + + setActiveSubMenu(null)}> + + + Audio + + + {audioTracks?.map((track, idx) => ( + { + setAudioTrack?.(track.index); + router.setParams({ + audioIndex: track.index.toString(), + }); + closeAllModals(); + }} + > + {track.name} + {audioIndex === track.index.toString() && ( + + )} + + ))} + + + )} + + + Cancel + + + + + ); }; diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx index 8c6d2799..caa6e278 100644 --- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx +++ b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx @@ -1,20 +1,25 @@ -import React, { useCallback, useMemo, useState } from "react"; -import { View, TouchableOpacity } from "react-native"; +import { Text } from "@/components/common/Text"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { Ionicons } from "@expo/vector-icons"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { Modal, TouchableOpacity, View } from "react-native"; import { useControlContext } from "../contexts/ControlContext"; import { useVideoContext } from "../contexts/VideoContext"; import { TranscodedSubtitle } from "../types"; -import { useAtomValue } from "jotai"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { SubtitleHelper } from "@/utils/SubtitleHelper"; interface DropdownViewProps { showControls: boolean; } const DropdownView: React.FC = ({ showControls }) => { + const [isMainModalVisible, setIsMainModalVisible] = useState(false); + const [activeSubMenu, setActiveSubMenu] = useState< + "subtitle" | "audio" | null + >(null); + const router = useRouter(); const api = useAtomValue(apiAtom); const ControlContext = useControlContext(); @@ -116,6 +121,27 @@ const DropdownView: React.FC = ({ showControls }) => { [mediaSource, subtitleIndex, audioIndex] ); + const closeAllModals = () => { + setIsMainModalVisible(false); + setActiveSubMenu(null); + }; + + const MenuOption = ({ + label, + onPress, + }: { + label: string; + onPress: () => void; + }) => ( + + {label} + + + ); + return ( = ({ showControls }) => { }} className="p-4" > - - - - - - - setIsMainModalVisible(true)} + > + + + + + - - - Subtitle - - - {allSubtitleTracksForTranscodingStream?.map( - (sub, idx: number) => ( - + {!activeSubMenu ? ( + <> + + + Settings + + + + setActiveSubMenu("subtitle")} + /> + setActiveSubMenu("audio")} + /> + + + ) : activeSubMenu === "subtitle" ? ( + <> + + setActiveSubMenu(null)}> + + + Subtitle + + + {allSubtitleTracksForTranscodingStream?.map((sub, idx) => ( + { + if ( + subtitleIndex === + (isOnTextSubtitle && sub.IsTextSubtitleStream + ? subtitleHelper + .getSourceSubtitleIndex(sub.index) + .toString() + : sub?.index.toString()) + ) + return; + + router.setParams({ + subtitleIndex: subtitleHelper .getSourceSubtitleIndex(sub.index) - .toString() - : sub?.index.toString()) - } - key={`subtitle-item-${idx}`} - onValueChange={() => { - if ( - subtitleIndex === + .toString(), + }); + + if (sub.IsTextSubtitleStream && isOnTextSubtitle) { + setSubtitleTrack && setSubtitleTrack(sub.index); + } else { + changeToImageBasedSub(sub.index); + } + closeAllModals(); + }} + > + {sub.name} + {subtitleIndex === (isOnTextSubtitle && sub.IsTextSubtitleStream ? subtitleHelper .getSourceSubtitleIndex(sub.index) .toString() - : sub?.index.toString()) - ) - return; + : sub?.index.toString()) && ( + + )} + + ))} + + + ) : ( + <> + + setActiveSubMenu(null)}> + + + Audio + + + {allAudio?.map((track, idx) => ( + { + if (audioIndex === track.index.toString()) return; + router.setParams({ + audioIndex: track.index.toString(), + }); + ChangeTranscodingAudio(track.index); + closeAllModals(); + }} + > + {track.name} + {audioIndex === track.index.toString() && ( + + )} + + ))} + + + )} - router.setParams({ - subtitleIndex: subtitleHelper - .getSourceSubtitleIndex(sub.index) - .toString(), - }); - - if (sub.IsTextSubtitleStream && isOnTextSubtitle) { - setSubtitleTrack && setSubtitleTrack(sub.index); - return; - } - changeToImageBasedSub(sub.index); - }} - > - - {sub.name} - - - ) - )} - - - - - Audio - - - {allAudio?.map((track, idx: number) => ( - { - if (audioIndex === track.index.toString()) return; - router.setParams({ - audioIndex: track.index.toString(), - }); - ChangeTranscodingAudio(track.index); - }} - > - - {track.name} - - - ))} - - - - + Cancel + + + + ); }; diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts deleted file mode 100644 index c7250c86..00000000 --- a/hooks/useImageColors.ts +++ /dev/null @@ -1,108 +0,0 @@ -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, useAtomValue } from "jotai"; -import { useEffect, useMemo } from "react"; -import { getColors } from "react-native-image-colors"; - -/** - * 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, - url, - disabled, -}: { - item?: BaseItemDto | null; - url?: string | null; - disabled?: boolean; -}) => { - const api = useAtomValue(apiAtom); - const [, setPrimaryColor] = useAtom(itemThemeColorAtom); - - const source = useMemo(() => { - if (!api) return; - if (url) return { uri: url }; - else if (item) - return getItemImage({ - item, - api, - variant: "Primary", - quality: 80, - width: 300, - }); - else return null; - }, [api, item]); - - useEffect(() => { - if (disabled) return; - 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) { - setPrimaryColor({ - primary: _primary, - text: _text, - }); - return; - } - - // Extract colors from the image - getColors(source.uri, { - fallback: "#fff", - cache: false, - }) - .then((colors) => { - let primary: string = "#fff"; - let text: string = "#000"; - let backup: string = "#fff"; - - // Select the appropriate color based on the platform - if (colors.platform === "android") { - primary = colors.dominant; - backup = colors.vibrant; - } else if (colors.platform === "ios") { - primary = colors.detail; - backup = colors.primary; - } - - // Adjust the primary color if it's too close to black - if (primary && isCloseToBlack(primary)) { - if (backup && !isCloseToBlack(backup)) primary = backup; - primary = adjustToNearBlack(primary); - } - - // Calculate the text color based on the primary color - if (primary) text = calculateTextColor(primary); - - setPrimaryColor({ - primary, - 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); - }); - } - }, [source?.uri, setPrimaryColor, disabled]); -};