forked from Ninjalama/streamyfin_mirror
Compare commits
1 Commits
refactor/b
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c051f6f61 |
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.18.0",
|
"version": "0.17.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 46,
|
"versionCode": 43,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Chromecast } from "@/components/Chromecast";
|
|||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
@@ -42,21 +42,15 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
headerRight: () => (
|
}}
|
||||||
<View className="">
|
/>
|
||||||
<Ionicons
|
<Stack.Screen
|
||||||
name="file-tray-full-outline"
|
name="settings/audio-language"
|
||||||
size={22}
|
options={{
|
||||||
color="white"
|
title: "Audio Language",
|
||||||
onPress={() => {
|
|
||||||
router.push("/logs");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
|||||||
61
app/(auth)/(tabs)/(home)/settings/audio-language.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/audio-language.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4 px-4">
|
||||||
|
<ListSection title="LANGUAGES">
|
||||||
|
{LANGUAGES.sort(sortByName).map((l) => (
|
||||||
|
<ListItem
|
||||||
|
key={l.value}
|
||||||
|
title={l.label}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
defaultAudioLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
settings?.defaultAudioLanguage?.value === l.value ? (
|
||||||
|
<Ionicons name="checkmark" size={24} color={Colors.primary} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListSection>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
|
||||||
|
if (a.label < b.label) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.label > b.label) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListInputItem } from "@/components/list/ListInputItem";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, readFromLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -16,10 +21,17 @@ import { toast } from "sonner-native";
|
|||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { deleteAllFiles } = useDownload();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { data: logs } = useQuery({
|
||||||
|
queryKey: ["logs"],
|
||||||
|
queryFn: async () => readFromLog(),
|
||||||
|
refetchInterval: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
@@ -51,6 +63,8 @@ export default function settings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -67,15 +81,46 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
registerBackgroundFetchAsync
|
registerBackgroundFetchAsync
|
||||||
</Button> */}
|
</Button> */}
|
||||||
<View>
|
<ListSection title="USER INFO">
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
<ListItem title="User" text={user?.Name} />
|
||||||
|
<ListItem title="Server" text={api?.basePath} />
|
||||||
|
<ListItem title="Token" text={api?.accessToken} />
|
||||||
|
</ListSection>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<ListSection title="MEDIA">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
title="Audio language"
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
iconAfter={
|
||||||
</View>
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
</View>
|
}
|
||||||
|
onPress={() => router.push("/settings/audio-language")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Subtitle language"
|
||||||
|
iconAfter={
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
|
}
|
||||||
|
onPress={() => router.push("/settings/subtitle-language")}
|
||||||
|
/>
|
||||||
|
<ListInputItem
|
||||||
|
textInputProps={{
|
||||||
|
placeholder: "30",
|
||||||
|
clearButtonMode: "never",
|
||||||
|
returnKeyType: "done",
|
||||||
|
}}
|
||||||
|
defaultValue={(settings?.forwardSkipTime || "").toString()}
|
||||||
|
title={"Forward skip"}
|
||||||
|
onChange={(val) => {
|
||||||
|
// 1. validate positive number
|
||||||
|
// 2. save settings
|
||||||
|
if (val.length === 0) return;
|
||||||
|
if (val.match(/^\d+$/)) {
|
||||||
|
} else {
|
||||||
|
toast.error("Invalid number");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListSection>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
@@ -123,6 +168,30 @@ export default function settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{logs?.map((log, index) => (
|
||||||
|
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
mb-1
|
||||||
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{log.level}
|
||||||
|
</Text>
|
||||||
|
<Text uiTextView selectable className="text-xs">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{logs?.length === 0 && (
|
||||||
|
<Text className="opacity-50">No logs available</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
61
app/(auth)/(tabs)/(home)/settings/subtitle-language.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/subtitle-language.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4 px-4">
|
||||||
|
<ListSection title="LANGUAGES">
|
||||||
|
{LANGUAGES.sort(sortByName).map((l) => (
|
||||||
|
<ListItem
|
||||||
|
key={l.value}
|
||||||
|
title={l.label}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
defaultSubtitleLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
settings?.defaultSubtitleLanguage?.value === l.value ? (
|
||||||
|
<Ionicons name="checkmark" size={24} color={Colors.primary} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListSection>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
|
||||||
|
if (a.label < b.label) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.label > b.label) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -43,7 +44,6 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
@@ -60,13 +60,12 @@ const Page = () => {
|
|||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
const [orientation] = useAtom(orientationAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
@@ -107,12 +106,11 @@ const Page = () => {
|
|||||||
[libraryId, sortOrderPreference]
|
[libraryId, sortOrderPreference]
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const getNumberOfColumns = useCallback(() => {
|
||||||
if (screenWidth < 300) return 2;
|
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 600) return 5;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 960) return 6;
|
||||||
if (screenWidth < 1000) return 6;
|
if (screenWidth < 1280) return 7;
|
||||||
if (screenWidth < 1500) return 7;
|
|
||||||
return 6;
|
return 6;
|
||||||
}, [screenWidth, orientation]);
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
@@ -221,7 +219,7 @@ const Page = () => {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<MemoizedTouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -232,10 +230,10 @@ const Page = () => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf:
|
||||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
? index % nrOfCols === 0
|
? index % 3 === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % nrOfCols === 0
|
: (index + 1) % 3 === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center"
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
@@ -246,7 +244,7 @@ const Page = () => {
|
|||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
@@ -431,7 +429,6 @@ const Page = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||||
@@ -440,10 +437,10 @@ const Page = () => {
|
|||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={orientation}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
estimatedItemSize={244}
|
||||||
numColumns={nrOfCols}
|
numColumns={getNumberOfColumns()}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
|||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import Video, {
|
import Video, {
|
||||||
OnProgressData,
|
OnProgressData,
|
||||||
SelectedTrackType,
|
|
||||||
VideoRef,
|
VideoRef,
|
||||||
|
SelectedTrack,
|
||||||
|
SelectedTrackType,
|
||||||
} from "react-native-video";
|
} from "react-native-video";
|
||||||
|
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||||
@@ -214,28 +216,18 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
width: screenDimensions.width,
|
width: screenDimensions.width,
|
||||||
height: screenDimensions.height,
|
height: screenDimensions.height,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
>
|
>
|
||||||
<StatusBar hidden />
|
<StatusBar hidden />
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowControls(!showControls);
|
setShowControls(!showControls);
|
||||||
}}
|
}}
|
||||||
style={{
|
className="absolute z-0 h-full w-full"
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: screenDimensions.width,
|
|
||||||
height: screenDimensions.height,
|
|
||||||
zIndex: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
|||||||
@@ -345,13 +345,6 @@ function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="logs"
|
|
||||||
options={{
|
|
||||||
presentation: "modal",
|
|
||||||
title: "Logs",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/play-offline-video"
|
name="(auth)/play-offline-video"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
107
app/login.tsx
107
app/login.tsx
@@ -2,12 +2,11 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +27,6 @@ const Login: React.FC = () => {
|
|||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const router = useRouter();
|
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -74,17 +72,7 @@ const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
try {
|
await login(credentials.username, credentials.password);
|
||||||
await login(credentials.username, credentials.password);
|
|
||||||
} catch (loginError) {
|
|
||||||
if (loginError instanceof Error) {
|
|
||||||
setError(loginError.message);
|
|
||||||
} else {
|
|
||||||
setError("An unexpected error occurred during login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError("Invalid credentials format");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -117,72 +105,37 @@ const Login: React.FC = () => {
|
|||||||
async function checkUrl(url: string) {
|
async function checkUrl(url: string) {
|
||||||
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
writeToLog("INFO", `Checking URL: ${url}`);
|
|
||||||
|
|
||||||
const timeout = 5000; // 5 seconds timeout
|
const protocols = ["https://", "http://"];
|
||||||
const controller = new AbortController();
|
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try HTTPS first
|
for (const protocol of protocols) {
|
||||||
const httpsUrl = `https://${url}/System/Info/Public`;
|
const controller = new AbortController();
|
||||||
try {
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
const response = await fetch(httpsUrl, {
|
|
||||||
mode: "cors",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return `https://${url}`;
|
|
||||||
} else {
|
|
||||||
writeToLog(
|
|
||||||
"WARN",
|
|
||||||
`HTTPS connection failed with status: ${response.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
writeToLog("WARN", "HTTPS connection failed - trying HTTP", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If HTTPS didn't work, try HTTP
|
try {
|
||||||
const httpUrl = `http://${url}/System/Info/Public`;
|
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
|
||||||
try {
|
mode: "cors",
|
||||||
const response = await fetch(httpUrl, {
|
signal: controller.signal,
|
||||||
mode: "cors",
|
});
|
||||||
signal: controller.signal,
|
clearTimeout(timeoutId);
|
||||||
});
|
if (response.ok) {
|
||||||
writeToLog("INFO", `HTTP response status: ${response.status}`);
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
if (response.ok) {
|
setServerName(data.ServerName || "");
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
return `${protocol}${url}`;
|
||||||
setServerName(data.ServerName || "");
|
}
|
||||||
return `http://${url}`;
|
} catch (e) {
|
||||||
} else {
|
const error = e as Error;
|
||||||
writeToLog(
|
if (error.name === "AbortError") {
|
||||||
"WARN",
|
console.log(`Request to ${protocol}${url} timed out`);
|
||||||
`HTTP connection failed with status: ${response.status}`
|
} else {
|
||||||
);
|
console.log(`Error checking ${protocol}${url}:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
writeToLog("ERROR", "HTTP connection failed", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If neither worked, return undefined
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`Failed to connect to ${url} using both HTTPS and HTTP`
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
if (error.name === "AbortError") {
|
|
||||||
writeToLog("ERROR", `Request to ${url} timed out`, error);
|
|
||||||
} else {
|
|
||||||
writeToLog("ERROR", `Unexpected error checking ${url}`, error);
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,16 +197,6 @@ const Login: React.FC = () => {
|
|||||||
style={{ flex: 1, height: "100%" }}
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
||||||
<View className="absolute top-4 right-4">
|
|
||||||
<Ionicons
|
|
||||||
name="file-tray-full-outline"
|
|
||||||
size={22}
|
|
||||||
color="white"
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/logs");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="px-4 -mt-20">
|
<View className="px-4 -mt-20">
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<Text className="text-3xl font-bold mb-1">
|
<Text className="text-3xl font-bold mb-1">
|
||||||
|
|||||||
58
app/logs.tsx
58
app/logs.tsx
@@ -1,58 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { readFromLog } from "@/utils/log";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const Logs: React.FC = () => {
|
|
||||||
const { data: logs } = useQuery({
|
|
||||||
queryKey: ["logs"],
|
|
||||||
queryFn: async () => (await readFromLog()).reverse(),
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnMount: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
className="flex-1 p-4"
|
|
||||||
contentContainerStyle={{ gap: 10, paddingBottom: insets.top }}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
{logs?.map((log, index) => (
|
|
||||||
<View key={index} className="border-b-neutral-800 border py-3">
|
|
||||||
<View className="flex flex-row justify-between items-center mb-2">
|
|
||||||
<Text
|
|
||||||
className={`
|
|
||||||
text-xs
|
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-neutral-500">
|
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text uiTextView selectable className="text-xs mb-1">
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
{log.data && (
|
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{log.data}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{logs?.length === 0 && (
|
|
||||||
<Text className="opacity-50">No logs available</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Logs;
|
|
||||||
@@ -26,7 +26,7 @@ import { useFocusEffect, useNavigation } from "expo-router";
|
|||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
@@ -59,11 +59,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mediaSource) {
|
|
||||||
Alert.alert("Error", "No media source found for this item.");
|
|
||||||
navigation.goBack();
|
|
||||||
}
|
|
||||||
}, [item, settings])
|
}, [item, settings])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
title?: string | null | undefined;
|
|
||||||
subTitle?: string | null | undefined;
|
|
||||||
children?: ReactNode;
|
|
||||||
iconAfter?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
title,
|
|
||||||
subTitle,
|
|
||||||
iconAfter,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col overflow-visible">
|
|
||||||
<Text className="font-bold ">{title}</Text>
|
|
||||||
{subTitle && (
|
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{iconAfter}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
22
components/_page_template.tsx
Normal file
22
components/_page_template.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
></ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/list/ListInputItem.tsx
Normal file
64
components/list/ListInputItem.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { PropsWithChildren, ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
TextInputProps,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
text?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
iconBefore?: ReactNode;
|
||||||
|
textInputProps?: TextInputProps;
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListInputItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
iconAfter,
|
||||||
|
iconBefore,
|
||||||
|
children,
|
||||||
|
onChange,
|
||||||
|
textInputProps,
|
||||||
|
defaultValue,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState<string>(defaultValue || "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{iconBefore && <View className="mr-2">{iconBefore}</View>}
|
||||||
|
<View>
|
||||||
|
<Text className="">{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="ml-auto">
|
||||||
|
<TextInput
|
||||||
|
inputMode="numeric"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
style={{ color: "white" }}
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
className=""
|
||||||
|
{...textInputProps}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{iconAfter && <View className="ml-2">{iconAfter}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
components/list/ListItem.tsx
Normal file
44
components/list/ListItem.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { PropsWithChildren, ReactNode, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
text?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
iconBefore?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
iconAfter,
|
||||||
|
iconBefore,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{iconBefore && <View className="mr-2">{iconBefore}</View>}
|
||||||
|
<View>
|
||||||
|
<Text className="">{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="ml-auto">
|
||||||
|
<Text selectable className="">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{iconAfter && <View className="ml-2">{iconAfter}</View>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
components/list/ListSection.tsx
Normal file
24
components/list/ListSection.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Children, PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListSection: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="ml-4 mb-1 text-xs text-neutral-500 uppercase">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
unregisterBackgroundFetchAsync,
|
unregisterBackgroundFetchAsync,
|
||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
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 * as BackgroundFetch from "expo-background-fetch";
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
@@ -18,7 +19,6 @@ import * as TaskManager from "expo-task-manager";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -32,8 +32,6 @@ import { Input } from "../common/Input";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
import axios from "axios";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
const wasPlayingRef = useRef(false);
|
const wasPlayingRef = useRef(false);
|
||||||
|
|
||||||
|
const updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
const current = ticksToSeconds(currentProgress);
|
||||||
|
const remaining = ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
|
setCurrentTime(current);
|
||||||
|
setRemainingTime(remaining);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
item.Id,
|
item.Id,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -169,23 +180,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
router.replace("/play-video");
|
router.replace("/play-video");
|
||||||
}, [nextItem, settings]);
|
}, [nextItem, settings]);
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
|
||||||
(currentProgress: number, maxValue: number) => {
|
|
||||||
const current = ticksToSeconds(currentProgress);
|
|
||||||
const remaining = ticksToSeconds(maxValue - currentProgress);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
|
||||||
setRemainingTime(remaining);
|
|
||||||
|
|
||||||
if (currentProgress === maxValue) {
|
|
||||||
setShowControls(true);
|
|
||||||
// Automatically play the next item if it exists
|
|
||||||
goToNextItem();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[goToNextItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => ({
|
() => ({
|
||||||
progress: progress.value,
|
progress: progress.value,
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.18.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.18.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -53,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.18.0" },
|
clientInfo: { name: "Streamyfin", version: "0.17.0" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -87,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.18.0"`,
|
}, DeviceId="${deviceId}", Version="0.17.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -213,35 +212,20 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
switch (error.response?.status) {
|
switch (error.response?.status) {
|
||||||
case 401:
|
case 401:
|
||||||
writeToLog("ERROR", "Invalid username or password");
|
|
||||||
throw new Error("Invalid username or password");
|
throw new Error("Invalid username or password");
|
||||||
case 403:
|
case 403:
|
||||||
writeToLog("ERROR", "User does not have permission to log in");
|
|
||||||
throw new Error("User does not have permission to log in");
|
throw new Error("User does not have permission to log in");
|
||||||
case 408:
|
case 408:
|
||||||
writeToLog(
|
|
||||||
"WARN",
|
|
||||||
"Server is taking too long to respond, try again later"
|
|
||||||
);
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Server is taking too long to respond, try again later"
|
"Server is taking too long to respond, try again later"
|
||||||
);
|
);
|
||||||
case 429:
|
case 429:
|
||||||
writeToLog(
|
|
||||||
"WARN",
|
|
||||||
"Server received too many requests, try again later"
|
|
||||||
);
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Server received too many requests, try again later"
|
"Server received too many requests, try again later"
|
||||||
);
|
);
|
||||||
case 500:
|
case 500:
|
||||||
writeToLog("ERROR", "There is a server error");
|
|
||||||
throw new Error("There is a server error");
|
throw new Error("There is a server error");
|
||||||
default:
|
default:
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
"An unexpected error occurred. Did you enter the server URL correctly?"
|
|
||||||
);
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"An unexpected error occurred. Did you enter the server URL correctly?"
|
"An unexpected error occurred. Did you enter the server URL correctly?"
|
||||||
);
|
);
|
||||||
@@ -328,9 +312,6 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
|
|||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
const inAuthGroup = segments[0] === "(auth)";
|
const inAuthGroup = segments[0] === "(auth)";
|
||||||
const inLogs = segments[0] === "logs";
|
|
||||||
|
|
||||||
if (inLogs) return;
|
|
||||||
|
|
||||||
if (!user?.Id && inAuthGroup) {
|
if (!user?.Id && inAuthGroup) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Settings } from "../atoms/settings";
|
|||||||
interface PlaySettings {
|
interface PlaySettings {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
bitrate: (typeof BITRATES)[0];
|
bitrate: (typeof BITRATES)[0];
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
audioIndex?: number | null;
|
audioIndex?: number | null;
|
||||||
subtitleIndex?: number | null;
|
subtitleIndex?: number | null;
|
||||||
}
|
}
|
||||||
@@ -29,9 +29,10 @@ export function getDefaultPlaySettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Get first media source
|
// 1. Get first media source
|
||||||
|
|
||||||
const mediaSource = item.MediaSources?.[0];
|
const mediaSource = item.MediaSources?.[0];
|
||||||
|
|
||||||
|
if (!mediaSource) throw new Error("No media source found");
|
||||||
|
|
||||||
// 2. Get default or preferred audio
|
// 2. Get default or preferred audio
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const writeToLog = async (
|
|||||||
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
|
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
|
||||||
logs.push(newEntry);
|
logs.push(newEntry);
|
||||||
|
|
||||||
const maxLogs = 1000;
|
const maxLogs = 100;
|
||||||
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
||||||
|
|
||||||
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
|
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
|
||||||
|
|||||||
Reference in New Issue
Block a user