mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
chore: remove deps
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user