chore: remove deps

This commit is contained in:
Fredrik Burmester
2025-01-04 17:08:00 +01:00
parent 8ea38a3efc
commit aa785b0f95
18 changed files with 1248 additions and 909 deletions

View File

@@ -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 { 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 { import {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackdropProps, BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { import { useQuery } from "@tanstack/react-query";
IssueType, import { Image } from "expo-image";
IssueTypeName, import { useLocalSearchParams } from "expo-router";
} from "@/utils/jellyseerr/server/constants/issue"; import React, { useCallback, useRef, useState } from "react";
import * as DropdownMenu from "zeego/dropdown-menu"; import { Modal, TouchableOpacity, View } from "react-native";
import { Input } from "@/components/common/Input"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { JellyserrRatings } from "@/components/Ratings";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -51,6 +50,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [isIssueTypeModalVisible, setIsIssueTypeModalVisible] = useState(false);
const { const {
data: details, data: details,
@@ -231,47 +231,68 @@ const Page: React.FC = () => {
</View> </View>
<View className="flex flex-col space-y-2 items-start"> <View className="flex flex-col space-y-2 items-start">
<View className="flex flex-col"> <View className="flex flex-col">
<DropdownMenu.Root> <View className="flex flex-col">
<DropdownMenu.Trigger> <Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
<View className="flex flex-col"> <TouchableOpacity
<Text className="opacity-50 mb-1 text-xs"> className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
Issue Type onPress={() => setIsIssueTypeModalVisible(true)}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: "Select an issue"}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
> >
<DropdownMenu.Label>Types</DropdownMenu.Label> <Text className="" numberOfLines={1}>
{Object.entries(IssueTypeName) {issueType ? IssueTypeName[issueType] : "Select an issue"}
.reverse() </Text>
.map(([key, value], idx) => ( <Ionicons
<DropdownMenu.Item name="chevron-down"
key={value} size={16}
onSelect={() => color="white"
setIssueType(key as unknown as IssueType) style={{ opacity: 0.5 }}
} />
</TouchableOpacity>
<Modal
visible={isIssueTypeModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsIssueTypeModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsIssueTypeModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Select Issue Type
</Text>
</View>
<View className="max-h-[50%]">
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => (
<TouchableOpacity
key={key}
className="p-4 border-b border-neutral-800"
onPress={() => {
setIssueType(key as unknown as IssueType);
setIsIssueTypeModalVisible(false);
}}
>
<Text className="text-center">{value}</Text>
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsIssueTypeModalVisible(false)}
> >
<DropdownMenu.ItemTitle> <Text className="text-center text-purple-400">
{value} Cancel
</DropdownMenu.ItemTitle> </Text>
</DropdownMenu.Item> </TouchableOpacity>
))} </View>
</DropdownMenu.Content> </TouchableOpacity>
</DropdownMenu.Root> </Modal>
</View>
</View> </View>
<Input <Input

View File

@@ -1,12 +1,44 @@
import { Text } from "@/components/common/Text";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { useState } from "react";
import * as DropdownMenu from "zeego/dropdown-menu"; import { Modal, Platform, TouchableOpacity, View } from "react-native";
export default function IndexLayout() { export default function IndexLayout() {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const MenuItem = ({
label,
selected,
onPress,
disabled = false,
}: {
label: string;
selected?: boolean;
onPress: () => void;
disabled?: boolean;
}) => (
<TouchableOpacity
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between ${
disabled ? "opacity-50" : ""
}`}
onPress={onPress}
disabled={disabled}
>
<Text className="text-base">{label}</Text>
{selected && <Ionicons name="checkmark" size={24} color="white" />}
</TouchableOpacity>
);
const MenuSection = ({ title }: { title: string }) => (
<View className="p-4 border-b border-neutral-800 bg-neutral-800/30">
<Text className="text-sm opacity-50 font-medium">{title}</Text>
</View>
);
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
@@ -22,163 +54,167 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
<DropdownMenu.Root> <Modal
<DropdownMenu.Trigger> visible={isMenuVisible}
<Ionicons transparent
name="ellipsis-horizontal-outline" animationType="slide"
size={24} onRequestClose={() => {
color="white" setIsMenuVisible(false);
/> setActiveSubmenu(null);
</DropdownMenu.Trigger> }}
<DropdownMenu.Content >
align={"end"} <TouchableOpacity
alignOffset={-10} className="flex-1 bg-black/50"
avoidCollisions={false} activeOpacity={1}
collisionPadding={0} onPress={() => {
loop={false} setIsMenuVisible(false);
side={"bottom"} setActiveSubmenu(null);
sideOffset={10} }}
> >
<DropdownMenu.Label>Display</DropdownMenu.Label> <View className="mt-auto bg-neutral-900 rounded-t-xl">
<DropdownMenu.Group key="display-group"> {!activeSubmenu ? (
<DropdownMenu.Sub> <>
<DropdownMenu.SubTrigger key="image-style-trigger"> <MenuSection title="Display" />
Display <MenuItem
</DropdownMenu.SubTrigger> label="Display"
<DropdownMenu.SubContent onPress={() => setActiveSubmenu("display")}
alignOffset={-10} />
avoidCollisions={true} <MenuItem
collisionPadding={0} label="Image style"
loop={true} onPress={() => setActiveSubmenu("imageStyle")}
sideOffset={10} />
> <MenuItem
<DropdownMenu.CheckboxItem label="Show titles"
key="display-option-1" selected={settings.libraryOptions.showTitles}
value={settings.libraryOptions.display === "row"} disabled={
onValueChange={() => settings.libraryOptions.imageStyle === "poster"
}
onPress={() => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: !settings.libraryOptions.showTitles,
},
});
}}
/>
<MenuItem
label="Show stats"
selected={settings.libraryOptions.showStats}
onPress={() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: !settings.libraryOptions.showStats,
},
});
}}
/>
</>
) : activeSubmenu === "display" ? (
<>
<View className="p-4 border-b border-neutral-800 flex-row items-center">
<TouchableOpacity
onPress={() => setActiveSubmenu(null)}
>
<Ionicons
name="chevron-back"
size={24}
color="white"
/>
</TouchableOpacity>
<Text className="text-lg font-bold ml-2">Display</Text>
</View>
<MenuItem
label="Row"
selected={settings.libraryOptions.display === "row"}
onPress={() => {
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,
display: "row", display: "row",
}, },
}) });
} setActiveSubmenu(null);
> }}
<DropdownMenu.ItemIndicator /> />
<DropdownMenu.ItemTitle key="display-title-1"> <MenuItem
Row label="List"
</DropdownMenu.ItemTitle> selected={settings.libraryOptions.display === "list"}
</DropdownMenu.CheckboxItem> onPress={() => {
<DropdownMenu.CheckboxItem
key="display-option-2"
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,
display: "list", display: "list",
}, },
}) });
setActiveSubmenu(null);
}}
/>
</>
) : activeSubmenu === "imageStyle" ? (
<>
<View className="p-4 border-b border-neutral-800 flex-row items-center">
<TouchableOpacity
onPress={() => setActiveSubmenu(null)}
>
<Ionicons
name="chevron-back"
size={24}
color="white"
/>
</TouchableOpacity>
<Text className="text-lg font-bold ml-2">
Image Style
</Text>
</View>
<MenuItem
label="Poster"
selected={
settings.libraryOptions.imageStyle === "poster"
} }
> onPress={() => {
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
List
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Image style
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="poster-option"
value={settings.libraryOptions.imageStyle === "poster"}
onValueChange={() =>
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,
imageStyle: "poster", imageStyle: "poster",
}, },
}) });
setActiveSubmenu(null);
}}
/>
<MenuItem
label="Cover"
selected={
settings.libraryOptions.imageStyle === "cover"
} }
> onPress={() => {
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
Poster
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="cover-option"
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,
imageStyle: "cover", imageStyle: "cover",
}, },
}) });
} setActiveSubmenu(null);
> }}
<DropdownMenu.ItemIndicator /> />
<DropdownMenu.ItemTitle key="cover-title"> </>
Cover ) : null}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key="show-titles-group">
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option"
value={settings.libraryOptions.showTitles}
onValueChange={(newValue) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title">
Show titles
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="show-stats-option"
value={settings.libraryOptions.showStats}
onValueChange={(newValue) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
Show stats
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator /> <TouchableOpacity
</DropdownMenu.Content> className="p-4 border-t border-neutral-800"
</DropdownMenu.Root> onPress={() => {
setIsMenuVisible(false);
setActiveSubmenu(null);
}}
>
<Text className="text-center text-purple-400">Done</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
), ),
}} }}
/> />

View File

@@ -73,18 +73,6 @@ export default function TabLayout() {
: () => ({ sfSymbol: "rectangle.stack" }), : () => ({ sfSymbol: "rectangle.stack" }),
}} }}
/> />
<NativeTabs.Screen
name="(custom-links)"
options={{
title: "Custom Links",
// @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? () => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }),
}}
/>
</NativeTabs> </NativeTabs>
</> </>
); );

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { Modal, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
@@ -16,6 +16,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source] [source]
@@ -25,50 +27,80 @@ export const AudioTrackSelector: React.FC<Props> = ({
() => audioStreams?.find((x) => x.Index === selected), () => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected] [audioStreams, selected]
); );
return ( return (
<View <>
className="flex shrink" <View
style={{ className="flex shrink"
minWidth: 50, style={{
}} minWidth: 50,
> }}
<DropdownMenu.Root> >
<DropdownMenu.Trigger> <View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}> <Text className="opacity-50 mb-1 text-xs">Audio</Text>
<Text className="opacity-50 mb-1 text-xs">Audio</Text> <TouchableOpacity
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
<Text className="" numberOfLines={1}> onPress={() => setIsModalVisible(true)}
{selectedAudioSteam?.DisplayTitle} >
<Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
</Text>
<Ionicons
name="chevron-down"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</TouchableOpacity>
</View>
</View>
<Modal
visible={isModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Audio Streams
</Text> </Text>
</View>
<View className="max-h-[50%]">
{audioStreams?.map((audio, idx: number) => (
<TouchableOpacity
key={idx.toString()}
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between`}
onPress={() => {
if (audio.Index !== null && audio.Index !== undefined) {
onChange(audio.Index);
setIsModalVisible(false);
}
}}
>
<Text>{audio.DisplayTitle}</Text>
{audio.Index === selected && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </TouchableOpacity>
<DropdownMenu.Content </Modal>
loop={true} </>
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -1,7 +1,7 @@
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View, Modal } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
@@ -49,6 +49,8 @@ export const BitrateSelector: React.FC<Props> = ({
inverted, inverted,
...props ...props
}) => { }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
return BITRATES.sort( return BITRATES.sort(
@@ -57,49 +59,81 @@ export const BitrateSelector: React.FC<Props> = ({
return BITRATES.sort( return BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity) (a, b) => (b.value || Infinity) - (a.value || Infinity)
); );
}, []); }, [inverted]);
return ( return (
<View <>
className="flex shrink" <View
style={{ className="flex shrink"
minWidth: 60, style={{
maxWidth: 200, minWidth: 60,
}} maxWidth: 200,
> }}
<DropdownMenu.Root> >
<DropdownMenu.Trigger> <View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}> <Text className="opacity-50 mb-1 text-xs">Quality</Text>
<Text className="opacity-50 mb-1 text-xs">Quality</Text> <TouchableOpacity
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
<Text style={{}} className="" numberOfLines={1}> onPress={() => setIsModalVisible(true)}
{BITRATES.find((b) => b.value === selected?.value)?.key} >
<Text className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
<Ionicons
name="chevron-down"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</TouchableOpacity>
</View>
</View>
<Modal
visible={isModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Select Quality
</Text> </Text>
</View>
<View className="max-h-[50%]">
{sorted.map((bitrate) => (
<TouchableOpacity
key={bitrate.key}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
onChange(bitrate);
setIsModalVisible(false);
}}
>
<Text>{bitrate.key}</Text>
{bitrate.value === selected?.value && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </TouchableOpacity>
<DropdownMenu.Content </Modal>
loop={false} </>
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{sorted.map((b) => (
<DropdownMenu.Item
key={b.key}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -11,7 +11,6 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries"; import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -44,7 +43,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const [settings] = useSettings(); const [settings] = useSettings();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true); const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350); const [headerHeight, setHeaderHeight] = useState(350);

View File

@@ -1,13 +1,12 @@
import { tc } from "@/utils/textTools"; import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
import { Ionicons } from "@expo/vector-icons";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react"; import { useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { Modal, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -21,6 +20,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const selectedName = useMemo( const selectedName = useMemo(
() => () =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
@@ -30,48 +31,80 @@ export const MediaSourceSelector: React.FC<Props> = ({
); );
return ( return (
<View <>
className="flex shrink" <View
style={{ className="flex shrink"
minWidth: 50, style={{
}} minWidth: 50,
> }}
<DropdownMenu.Root> >
<DropdownMenu.Trigger> <View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}> <Text className="opacity-50 mb-1 text-xs">Video</Text>
<Text className="opacity-50 mb-1 text-xs">Video</Text> <TouchableOpacity
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center"> className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
<Text numberOfLines={1}>{selectedName}</Text> onPress={() => setIsModalVisible(true)}
>
<Text numberOfLines={1}>{selectedName}</Text>
<Ionicons
name="chevron-down"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</TouchableOpacity>
</View>
</View>
<Modal
visible={isModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Media Sources
</Text>
</View>
<View className="max-h-[50%]">
{item.MediaSources?.map((source, idx: number) => (
<TouchableOpacity
key={idx.toString()}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
onChange(source);
setIsModalVisible(false);
}}
>
<Text>
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
</Text>
{source.Id === selected?.Id && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </TouchableOpacity>
<DropdownMenu.Content </Modal>
loop={true} </>
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
onChange(source);
}}
>
<DropdownMenu.ItemTitle>
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -1,10 +1,10 @@
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View, Modal } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { Ionicons } from "@expo/vector-icons";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -20,6 +20,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
isTranscoding, isTranscoding,
...props ...props
}) => { }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []); const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
@@ -38,59 +40,98 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (subtitleStreams.length === 0) return null; if (subtitleStreams.length === 0) return null;
return ( return (
<View <>
className="flex col shrink justify-start place-self-start items-start" <View
style={{ className="flex col shrink justify-start place-self-start items-start"
minWidth: 60, style={{
maxWidth: 200, minWidth: 60,
}} maxWidth: 200,
> }}
<DropdownMenu.Root> >
<DropdownMenu.Trigger> <View className="flex flex-col" {...props}>
<View className="flex flex-col " {...props}> <Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text> <TouchableOpacity
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
<Text className=" "> onPress={() => setIsModalVisible(true)}
{selectedSubtitleSteam >
? tc(selectedSubtitleSteam?.DisplayTitle, 7) <Text>
: "None"} {selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: "None"}
</Text>
<Ionicons
name="chevron-down"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</TouchableOpacity>
</View>
</View>
<Modal
visible={isModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Subtitle Tracks
</Text> </Text>
</View>
<View className="max-h-[50%]">
<TouchableOpacity
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
onChange(-1);
setIsModalVisible(false);
}}
>
<Text>None</Text>
{selected === -1 && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
{subtitleStreams?.map((subtitle, idx: number) => (
<TouchableOpacity
key={idx.toString()}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
if (
subtitle.Index !== undefined &&
subtitle.Index !== null
) {
onChange(subtitle.Index);
setIsModalVisible(false);
}
}}
>
<Text>{subtitle.DisplayTitle}</Text>
{subtitle.Index === selected && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </TouchableOpacity>
<DropdownMenu.Content </Modal>
loop={true} </>
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
<DropdownMenu.Item
key={"-1"}
onSelect={() => {
onChange(-1);
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -1,4 +1,3 @@
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";

View File

@@ -1,8 +1,8 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View, Modal } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { Ionicons } from "@expo/vector-icons";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
@@ -29,6 +29,8 @@ export const SeasonDropdown: React.FC<Props> = ({
state, state,
onSelect, onSelect,
}) => { }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const keys = useMemo<SeasonKeys>( const keys = useMemo<SeasonKeys>(
() => () =>
item.Type === "Episode" item.Type === "Episode"
@@ -55,7 +57,6 @@ export const SeasonDropdown: React.FC<Props> = ({
let initialIndex: number | undefined; let initialIndex: number | undefined;
if (initialSeasonIndex !== undefined) { if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some( const seasonExists = seasons.some(
(season: any) => season[keys.index] === initialSeasonIndex (season: any) => season[keys.index] === initialSeasonIndex
); );
@@ -65,7 +66,6 @@ export const SeasonDropdown: React.FC<Props> = ({
} }
if (initialIndex === undefined) { 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 season1 = seasons.find((season: any) => season[keys.index] === 1);
const season0 = seasons.find((season: any) => season[keys.index] === 0); const season0 = seasons.find((season: any) => season[keys.index] === 0);
const firstSeason = season1 || season0 || seasons[0]; const firstSeason = season1 || season0 || seasons[0];
@@ -87,35 +87,65 @@ export const SeasonDropdown: React.FC<Props> = ({
Number(a[keys.index]) - Number(b[keys.index]); Number(a[keys.index]) - Number(b[keys.index]);
return ( return (
<DropdownMenu.Root> <>
<DropdownMenu.Trigger> <TouchableOpacity
<View className="flex flex-row"> className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> onPress={() => setIsModalVisible(true)}
<Text>Season {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
> >
<DropdownMenu.Label>Seasons</DropdownMenu.Label> <Text>Season {seasonIndex}</Text>
{seasons?.sort(sortByIndex).map((season: any) => ( <Ionicons
<DropdownMenu.Item name="chevron-down"
key={season[keys.title]} size={16}
onSelect={() => onSelect(season)} color="white"
> style={{ opacity: 0.5, marginLeft: 8 }}
<DropdownMenu.ItemTitle> />
{season[keys.title]} </TouchableOpacity>
</DropdownMenu.ItemTitle>
</DropdownMenu.Item> <Modal
))} visible={isModalVisible}
</DropdownMenu.Content> transparent
</DropdownMenu.Root> animationType="slide"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Select Season
</Text>
</View>
<View className="max-h-[50%]">
{seasons?.sort(sortByIndex).map((season: any) => (
<TouchableOpacity
key={season[keys.title]}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
onSelect(season);
setIsModalVisible(false);
}}
>
<Text>{season[keys.title]}</Text>
{Number(season[keys.index]) === Number(seasonIndex) && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</>
); );
}; };

View File

@@ -5,7 +5,6 @@ import {
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";

View File

@@ -1,8 +1,9 @@
import { TouchableOpacity, View, ViewProps } from "react-native"; import { TouchableOpacity, View, ViewProps, Modal } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useMedia } from "./MediaContext"; import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler"; import { Switch } from "react-native-gesture-handler";
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -10,69 +11,35 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia(); const media = useMedia();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
const [isModalVisible, setIsModalVisible] = useState(false);
if (!settings) return null; if (!settings) return null;
return ( return (
<View> <View>
<Text className="text-lg font-bold mb-2">Audio</Text> <Text className="text-lg font-bold mb-2">Audio</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800"> <View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View <View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink"> <View className="flex flex-col shrink">
<Text className="font-semibold">Audio language</Text> <Text className="font-semibold">Audio language</Text>
<Text className="text-xs opacity-50"> <Text className="text-xs opacity-50">
Choose a default audio language. Choose a default audio language.
</Text> </Text>
</View> </View>
<DropdownMenu.Root> <TouchableOpacity
<DropdownMenu.Trigger> className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> onPress={() => setIsModalVisible(true)}
<Text> >
{settings?.defaultAudioLanguage?.DisplayName || "None"} <Text>{settings?.defaultAudioLanguage?.DisplayName || "None"}</Text>
</Text> <Ionicons
</TouchableOpacity> name="chevron-down"
</DropdownMenu.Trigger> size={16}
<DropdownMenu.Content color="white"
loop={true} style={{ opacity: 0.5, marginLeft: 8 }}
side="bottom" />
align="start" </TouchableOpacity>
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
<View className="flex flex-col"> <View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col"> <View className="flex flex-col">
@@ -89,6 +56,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
/> />
</View> </View>
</View> </View>
<View className="flex flex-col"> <View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col"> <View className="flex flex-col">
@@ -109,6 +77,71 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
</View> </View>
</View> </View>
</View> </View>
<Modal
visible={isModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Select Language
</Text>
</View>
<View className="max-h-[50%]">
<TouchableOpacity
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
updateSettings({
defaultAudioLanguage: null,
});
setIsModalVisible(false);
}}
>
<Text>None</Text>
{!settings?.defaultAudioLanguage && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
{cultures?.map((l) => (
<TouchableOpacity
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
updateSettings({
defaultAudioLanguage: l,
});
setIsModalVisible(false);
}}
>
<Text>{l.DisplayName}</Text>
{settings?.defaultAudioLanguage
?.ThreeLetterISOLanguageName ===
l.ThreeLetterISOLanguageName && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</View> </View>
); );
}; };

View File

@@ -1,17 +1,18 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useState } from "react"; import React, { useState } from "react";
import { import {
Linking, Linking,
Modal,
Switch, Switch,
TouchableOpacity, TouchableOpacity,
View, View,
ViewProps, ViewProps,
} from "react-native"; } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -21,7 +22,6 @@ import { JellyseerrSettings } from "./Jellyseerr";
import { MediaProvider } from "./MediaContext"; import { MediaProvider } from "./MediaContext";
import { MediaToggles } from "./MediaToggles"; import { MediaToggles } from "./MediaToggles";
import { SubtitleToggles } from "./SubtitleToggles"; import { SubtitleToggles } from "./SubtitleToggles";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const SettingToggles: React.FC<Props> = ({ ...props }) => { export const SettingToggles: React.FC<Props> = ({ ...props }) => {
@@ -31,6 +31,8 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>(""); const [marlinUrl, setMarlinUrl] = useState<string>("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSearchEngineModalVisible, setIsSearchEngineModalVisible] =
useState(false);
const { const {
data: mediaListCollections, data: mediaListCollections,
@@ -54,6 +56,13 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
staleTime: 0, 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; if (!settings) return null;
return ( return (
@@ -183,54 +192,27 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View> </View>
<View className="flex flex-col"> <View className="flex flex-col">
<View <View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink"> <View className="flex flex-col shrink">
<Text className="font-semibold">Search engine</Text> <Text className="font-semibold">Search engine</Text>
<Text className="text-xs opacity-50"> <Text className="text-xs opacity-50">
Choose the search engine you want to use. Choose the search engine you want to use.
</Text> </Text>
</View> </View>
<DropdownMenu.Root> <TouchableOpacity
<DropdownMenu.Trigger> className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> onPress={() => setIsSearchEngineModalVisible(true)}
<Text>{settings.searchEngine}</Text> >
</TouchableOpacity> <Text>{settings.searchEngine}</Text>
</DropdownMenu.Trigger> <Ionicons
<DropdownMenu.Content name="chevron-down"
loop={true} size={16}
side="bottom" color="white"
align="start" style={{ opacity: 0.5, marginLeft: 8 }}
alignOffset={0} />
avoidCollisions={true} </TouchableOpacity>
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ searchEngine: "Marlin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
{settings.searchEngine === "Marlin" && ( {settings.searchEngine === "Marlin" && (
<View className="flex flex-col bg-neutral-900 px-4 pb-4"> <View className="flex flex-col bg-neutral-900 px-4 pb-4">
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
@@ -269,6 +251,55 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View> </View>
)} )}
</View> </View>
<Modal
visible={isSearchEngineModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsSearchEngineModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsSearchEngineModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Select Search Engine
</Text>
</View>
<View className="max-h-[50%]">
{searchEngines.map((engine) => (
<TouchableOpacity
key={engine.id}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
updateSettings({
searchEngine: engine.id,
});
queryClient.invalidateQueries({ queryKey: ["search"] });
setIsSearchEngineModalVisible(false);
}}
>
<Text>{engine.name}</Text>
{settings.searchEngine === engine.id && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsSearchEngineModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</View> </View>
</View> </View>
<JellyseerrSettings /> <JellyseerrSettings />

View File

@@ -1,9 +1,10 @@
import { TouchableOpacity, View, ViewProps } from "react-native"; import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu"; 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 { Text } from "../common/Text";
import { useMedia } from "./MediaContext"; import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -11,6 +12,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia(); const media = useMedia();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
const [isLanguageModalVisible, setIsLanguageModalVisible] = useState(false);
const [isModeModalVisible, setIsModeModalVisible] = useState(false);
if (!settings) return null; if (!settings) return null;
const subtitleModes = [ const subtitleModes = [
@@ -24,69 +28,31 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
return ( return (
<View> <View>
<Text className="text-lg font-bold mb-2">Subtitle</Text> <Text className="text-lg font-bold mb-2">Subtitle</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800"> <View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View <View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink"> <View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle language</Text> <Text className="font-semibold">Subtitle language</Text>
<Text className="text-xs opacity-50"> <Text className="text-xs opacity-50">
Choose a default subtitle language. Choose a default subtitle language.
</Text> </Text>
</View> </View>
<DropdownMenu.Root> <TouchableOpacity
<DropdownMenu.Trigger> className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> onPress={() => setIsLanguageModalVisible(true)}
<Text> >
{settings?.defaultSubtitleLanguage?.DisplayName || "None"} <Text>
</Text> {settings?.defaultSubtitleLanguage?.DisplayName || "None"}
</TouchableOpacity> </Text>
</DropdownMenu.Trigger> <Ionicons
<DropdownMenu.Content name="chevron-down"
loop={true} size={16}
side="bottom" color="white"
align="start" style={{ opacity: 0.5, marginLeft: 8 }}
alignOffset={0} />
avoidCollisions={true} </TouchableOpacity>
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
<View <View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink"> <View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle Mode</Text> <Text className="font-semibold">Subtitle Mode</Text>
<Text className="text-xs opacity-50 mr-2"> <Text className="text-xs opacity-50 mr-2">
@@ -95,36 +61,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
multiple options are available. multiple options are available.
</Text> </Text>
</View> </View>
<DropdownMenu.Root> <TouchableOpacity
<DropdownMenu.Trigger> className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> onPress={() => setIsModeModalVisible(true)}
<Text>{settings?.subtitleMode || "Loading"}</Text> >
</TouchableOpacity> <Text>{settings?.subtitleMode || "Loading"}</Text>
</DropdownMenu.Trigger> <Ionicons
<DropdownMenu.Content name="chevron-down"
loop={true} size={16}
side="bottom" color="white"
align="start" style={{ opacity: 0.5, marginLeft: 8 }}
alignOffset={0} />
avoidCollisions={true} </TouchableOpacity>
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
{subtitleModes?.map((l) => (
<DropdownMenu.Item
key={l}
onSelect={() => {
updateSettings({
subtitleMode: l,
});
}}
>
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
<View className="flex flex-col"> <View className="flex flex-col">
@@ -186,6 +134,119 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
</View> </View>
</View> </View>
</View> </View>
<Modal
visible={isLanguageModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsLanguageModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsLanguageModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Select Language
</Text>
</View>
<View className="max-h-[50%]">
<TouchableOpacity
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
setIsLanguageModalVisible(false);
}}
>
<Text>None</Text>
{!settings?.defaultSubtitleLanguage && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
{cultures?.map((l) => (
<TouchableOpacity
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
setIsLanguageModalVisible(false);
}}
>
<Text>{l.DisplayName}</Text>
{settings?.defaultSubtitleLanguage
?.ThreeLetterISOLanguageName ===
l.ThreeLetterISOLanguageName && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsLanguageModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
{/* Subtitle Mode Selection Modal */}
<Modal
visible={isModeModalVisible}
transparent
animationType="slide"
onRequestClose={() => setIsModeModalVisible(false)}
>
<TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={() => setIsModeModalVisible(false)}
>
<View className="mt-auto bg-neutral-900 rounded-t-xl">
<View className="p-4 border-b border-neutral-800">
<Text className="text-lg font-bold text-center">
Select Subtitle Mode
</Text>
</View>
<View className="max-h-[50%]">
{subtitleModes?.map((mode) => (
<TouchableOpacity
key={mode}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
updateSettings({
subtitleMode: mode,
});
setIsModeModalVisible(false);
}}
>
<Text>{mode}</Text>
{settings?.subtitleMode === mode && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={() => setIsModeModalVisible(false)}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</View> </View>
); );
}; };

View File

@@ -19,7 +19,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;

View File

@@ -1,13 +1,13 @@
import React, { useMemo, useState } from "react"; 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 { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useControlContext } from "../contexts/ControlContext"; import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
import { EmbeddedSubtitle, ExternalSubtitle } from "../types"; import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/common/Text";
interface DropdownViewDirectProps { interface DropdownViewDirectProps {
showControls: boolean; showControls: boolean;
@@ -16,6 +16,11 @@ interface DropdownViewDirectProps {
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
showControls, showControls,
}) => { }) => {
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
const [activeSubMenu, setActiveSubMenu] = useState<
"subtitle" | "audio" | null
>(null);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const ControlContext = useControlContext(); const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource; const mediaSource = ControlContext?.mediaSource;
@@ -51,12 +56,10 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
deliveryUrl: s.DeliveryUrl, deliveryUrl: s.DeliveryUrl,
})) || []; })) || [];
// Combine embedded subs with external subs only if not offline
return [...embeddedSubs, ...externalSubs] as ( return [...embeddedSubs, ...externalSubs] as (
| EmbeddedSubtitle | EmbeddedSubtitle
| ExternalSubtitle | ExternalSubtitle
)[]; )[];
return embeddedSubs as EmbeddedSubtitle[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]); }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
const { subtitleIndex, audioIndex } = useLocalSearchParams<{ const { subtitleIndex, audioIndex } = useLocalSearchParams<{
@@ -67,87 +70,143 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
bitrateValue: string; bitrateValue: string;
}>(); }>();
const closeAllModals = () => {
setIsMainModalVisible(false);
setActiveSubMenu(null);
};
const MenuOption = ({
label,
onPress,
}: {
label: string;
onPress: () => void;
}) => (
<TouchableOpacity
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={onPress}
>
<Text>{label}</Text>
<Ionicons name="chevron-forward" size={20} color="white" />
</TouchableOpacity>
);
return ( return (
<DropdownMenu.Root> <>
<DropdownMenu.Trigger> <TouchableOpacity
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"> className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} /> onPress={() => setIsMainModalVisible(true)}
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
> >
<DropdownMenu.Sub> <Ionicons name="ellipsis-horizontal" size={24} color="white" />
<DropdownMenu.SubTrigger key="subtitle-trigger"> </TouchableOpacity>
Subtitle
</DropdownMenu.SubTrigger> <Modal
<DropdownMenu.SubContent visible={isMainModalVisible}
alignOffset={-10} transparent
avoidCollisions={true} animationType="slide"
collisionPadding={0} onRequestClose={closeAllModals}
loop={true} >
sideOffset={10} <TouchableOpacity
> className="flex-1 bg-black/50"
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => ( activeOpacity={1}
<DropdownMenu.CheckboxItem onPress={closeAllModals}
key={`subtitle-item-${idx}`} >
value={subtitleIndex === sub.index.toString()} <View className="mt-auto bg-neutral-900 rounded-t-xl">
onValueChange={() => { {!activeSubMenu ? (
if ("deliveryUrl" in sub && sub.deliveryUrl) { <>
setSubtitleURL && <View className="p-4 border-b border-neutral-800">
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name); <Text className="text-lg font-bold text-center">
} else { Settings
setSubtitleTrack && setSubtitleTrack(sub.index); </Text>
} </View>
router.setParams({ <View>
subtitleIndex: sub.index.toString(), <MenuOption
}); label="Subtitle"
}} onPress={() => setActiveSubMenu("subtitle")}
> />
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}> <MenuOption
{sub.name} label="Audio"
</DropdownMenu.ItemTitle> onPress={() => setActiveSubMenu("audio")}
</DropdownMenu.CheckboxItem> />
))} </View>
</DropdownMenu.SubContent> </>
</DropdownMenu.Sub> ) : activeSubMenu === "subtitle" ? (
<DropdownMenu.Sub> <>
<DropdownMenu.SubTrigger key="audio-trigger"> <View className="p-4 border-b border-neutral-800 flex-row items-center">
Audio <TouchableOpacity onPress={() => setActiveSubMenu(null)}>
</DropdownMenu.SubTrigger> <Ionicons name="chevron-back" size={24} color="white" />
<DropdownMenu.SubContent </TouchableOpacity>
alignOffset={-10} <Text className="text-lg font-bold ml-2">Subtitle</Text>
avoidCollisions={true} </View>
collisionPadding={0} <View className="max-h-[50%]">
loop={true} {allSubtitleTracksForDirectPlay?.map((sub, idx) => (
sideOffset={10} <TouchableOpacity
> key={`subtitle-${idx}`}
{audioTracks?.map((track, idx: number) => ( className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
<DropdownMenu.CheckboxItem onPress={() => {
key={`audio-item-${idx}`} if ("deliveryUrl" in sub && sub.deliveryUrl) {
value={audioIndex === track.index.toString()} setSubtitleURL?.(
onValueChange={() => { api?.basePath + sub.deliveryUrl,
setAudioTrack && setAudioTrack(track.index); sub.name
router.setParams({ );
audioIndex: track.index.toString(), } else {
}); setSubtitleTrack?.(sub.index);
}} }
> router.setParams({
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}> subtitleIndex: sub.index.toString(),
{track.name} });
</DropdownMenu.ItemTitle> closeAllModals();
</DropdownMenu.CheckboxItem> }}
))} >
</DropdownMenu.SubContent> <Text>{sub.name}</Text>
</DropdownMenu.Sub> {subtitleIndex === sub.index.toString() && (
</DropdownMenu.Content> <Ionicons name="checkmark" size={24} color="white" />
</DropdownMenu.Root> )}
</TouchableOpacity>
))}
</View>
</>
) : (
<>
<View className="p-4 border-b border-neutral-800 flex-row items-center">
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
<Ionicons name="chevron-back" size={24} color="white" />
</TouchableOpacity>
<Text className="text-lg font-bold ml-2">Audio</Text>
</View>
<View className="max-h-[50%]">
{audioTracks?.map((track, idx) => (
<TouchableOpacity
key={`audio-${idx}`}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
setAudioTrack?.(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
closeAllModals();
}}
>
<Text>{track.name}</Text>
{audioIndex === track.index.toString() && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
</>
)}
<TouchableOpacity
className="p-4 border-t border-neutral-800"
onPress={closeAllModals}
>
<Text className="text-center text-purple-400">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</>
); );
}; };

View File

@@ -1,20 +1,25 @@
import React, { useCallback, useMemo, useState } from "react"; import { Text } from "@/components/common/Text";
import { View, TouchableOpacity } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { Ionicons } from "@expo/vector-icons"; 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 { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
import { TranscodedSubtitle } from "../types"; 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 { interface DropdownViewProps {
showControls: boolean; showControls: boolean;
} }
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => { const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
const [activeSubMenu, setActiveSubMenu] = useState<
"subtitle" | "audio" | null
>(null);
const router = useRouter(); const router = useRouter();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const ControlContext = useControlContext(); const ControlContext = useControlContext();
@@ -116,6 +121,27 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
[mediaSource, subtitleIndex, audioIndex] [mediaSource, subtitleIndex, audioIndex]
); );
const closeAllModals = () => {
setIsMainModalVisible(false);
setActiveSubMenu(null);
};
const MenuOption = ({
label,
onPress,
}: {
label: string;
onPress: () => void;
}) => (
<TouchableOpacity
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={onPress}
>
<Text>{label}</Text>
<Ionicons name="chevron-forward" size={20} color="white" />
</TouchableOpacity>
);
return ( return (
<View <View
style={{ style={{
@@ -125,108 +151,135 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
}} }}
className="p-4" className="p-4"
> >
<DropdownMenu.Root> <TouchableOpacity
<DropdownMenu.Trigger> className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"> onPress={() => setIsMainModalVisible(true)}
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} /> >
</TouchableOpacity> <Ionicons name="ellipsis-horizontal" size={24} color="white" />
</DropdownMenu.Trigger> </TouchableOpacity>
<DropdownMenu.Content
loop={true} <Modal
side="bottom" visible={isMainModalVisible}
align="start" transparent
alignOffset={0} animationType="slide"
avoidCollisions={true} onRequestClose={closeAllModals}
collisionPadding={8} >
sideOffset={8} <TouchableOpacity
className="flex-1 bg-black/50"
activeOpacity={1}
onPress={closeAllModals}
> >
<DropdownMenu.Sub> <View className="mt-auto bg-neutral-900 rounded-t-xl">
<DropdownMenu.SubTrigger key="subtitle-trigger"> {!activeSubMenu ? (
Subtitle <>
</DropdownMenu.SubTrigger> <View className="p-4 border-b border-neutral-800">
<DropdownMenu.SubContent <Text className="text-lg font-bold text-center">
alignOffset={-10} Settings
avoidCollisions={true} </Text>
collisionPadding={0} </View>
loop={true} <View>
sideOffset={10} <MenuOption
> label="Subtitle"
{allSubtitleTracksForTranscodingStream?.map( onPress={() => setActiveSubMenu("subtitle")}
(sub, idx: number) => ( />
<DropdownMenu.CheckboxItem <MenuOption
value={ label="Audio"
subtitleIndex === onPress={() => setActiveSubMenu("audio")}
(isOnTextSubtitle && sub.IsTextSubtitleStream />
? subtitleHelper </View>
</>
) : activeSubMenu === "subtitle" ? (
<>
<View className="p-4 border-b border-neutral-800 flex-row items-center">
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
<Ionicons name="chevron-back" size={24} color="white" />
</TouchableOpacity>
<Text className="text-lg font-bold ml-2">Subtitle</Text>
</View>
<View className="max-h-[50%]">
{allSubtitleTracksForTranscodingStream?.map((sub, idx) => (
<TouchableOpacity
key={`subtitle-${idx}`}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
if (
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
)
return;
router.setParams({
subtitleIndex: subtitleHelper
.getSourceSubtitleIndex(sub.index) .getSourceSubtitleIndex(sub.index)
.toString() .toString(),
: sub?.index.toString()) });
}
key={`subtitle-item-${idx}`} if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
onValueChange={() => { setSubtitleTrack && setSubtitleTrack(sub.index);
if ( } else {
subtitleIndex === changeToImageBasedSub(sub.index);
}
closeAllModals();
}}
>
<Text>{sub.name}</Text>
{subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream (isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper ? subtitleHelper
.getSourceSubtitleIndex(sub.index) .getSourceSubtitleIndex(sub.index)
.toString() .toString()
: sub?.index.toString()) : sub?.index.toString()) && (
) <Ionicons name="checkmark" size={24} color="white" />
return; )}
</TouchableOpacity>
))}
</View>
</>
) : (
<>
<View className="p-4 border-b border-neutral-800 flex-row items-center">
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
<Ionicons name="chevron-back" size={24} color="white" />
</TouchableOpacity>
<Text className="text-lg font-bold ml-2">Audio</Text>
</View>
<View className="max-h-[50%]">
{allAudio?.map((track, idx) => (
<TouchableOpacity
key={`audio-${idx}`}
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
onPress={() => {
if (audioIndex === track.index.toString()) return;
router.setParams({
audioIndex: track.index.toString(),
});
ChangeTranscodingAudio(track.index);
closeAllModals();
}}
>
<Text>{track.name}</Text>
{audioIndex === track.index.toString() && (
<Ionicons name="checkmark" size={24} color="white" />
)}
</TouchableOpacity>
))}
</View>
</>
)}
router.setParams({ <TouchableOpacity
subtitleIndex: subtitleHelper className="p-4 border-t border-neutral-800"
.getSourceSubtitleIndex(sub.index) onPress={closeAllModals}
.toString(),
});
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
changeToImageBasedSub(sub.index);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
> >
{allAudio?.map((track, idx: number) => ( <Text className="text-center text-purple-400">Cancel</Text>
<DropdownMenu.CheckboxItem </TouchableOpacity>
key={`audio-item-${idx}`} </View>
value={audioIndex === track.index.toString()} </TouchableOpacity>
onValueChange={() => { </Modal>
if (audioIndex === track.index.toString()) return;
router.setParams({
audioIndex: track.index.toString(),
});
ChangeTranscodingAudio(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
); );
}; };

View File

@@ -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]);
};