mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-06 09:46:17 +00:00
Compare commits
3 Commits
v0.15.0
...
feat/syncp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4eaabce7a | ||
|
|
788b4bcbd2 | ||
|
|
acbc650ccf |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ''
|
||||||
labels: '❌ bug'
|
labels: ''
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: ''
|
title: ''
|
||||||
labels: '✨ enhancement'
|
labels: ''
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.15.0",
|
"version": "0.12.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": 41,
|
"versionCode": 36,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon.png"
|
"foregroundImage": "./assets/images/icon.png"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
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 { Feather } from "@expo/vector-icons";
|
import { Feather, Ionicons } 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";
|
||||||
|
|
||||||
@@ -26,21 +26,31 @@ export default function IndexLayout() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className="p-2"
|
|
||||||
>
|
>
|
||||||
<Feather name="download" color={"white"} size={22} />
|
<Feather name="download" color={"white"} size={22} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/syncplay");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="people" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
<Chromecast />
|
<Chromecast />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
className="p-2 "
|
|
||||||
>
|
>
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
<View className="h-10 aspect-square flex items-center justify-center rounded">
|
||||||
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
@@ -58,19 +68,16 @@ export default function IndexLayout() {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="syncplay"
|
||||||
|
options={{
|
||||||
|
title: "Syncplay",
|
||||||
|
presentation: "modal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
|
||||||
name="collections/[collectionId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ export default function index() {
|
|||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
className="flex flex-col pt-4 pb-24 gap-y-2"
|
className="flex flex-col pt-4 pb-24 gap-y-4"
|
||||||
>
|
>
|
||||||
<LargeMovieCarousel />
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ import { SettingToggles } from "@/components/settings/SettingToggles";
|
|||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
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 { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { red } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
@@ -30,36 +26,6 @@ export default function settings() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
|
||||||
Alert.prompt(
|
|
||||||
"Quick connect",
|
|
||||||
"Enter the quick connect code",
|
|
||||||
async (text) => {
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
|
||||||
code: text,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
console.log(res.status, res.statusText, res.data);
|
|
||||||
if (res.status === 200) {
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
|
||||||
} else {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -72,62 +38,40 @@ export default function settings() {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem title="User" subTitle={user?.Name} />
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
|
||||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
|
||||||
Authorize
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View>
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="font-bold text-lg mb-2">Tests</Text>
|
<Button color="black" onPress={logout}>
|
||||||
<Button
|
Log out
|
||||||
onPress={() => {
|
</Button>
|
||||||
toast.success("Download started");
|
<Button
|
||||||
}}
|
color="red"
|
||||||
color="black"
|
onPress={async () => {
|
||||||
>
|
await deleteAllFiles();
|
||||||
Test toast
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete all downloaded files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={async () => {
|
||||||
|
await clearLogs();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete all logs
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<Button color="black" onPress={logout}>
|
|
||||||
Log out
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await clearLogs();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
|
|||||||
145
app/(auth)/(tabs)/(home)/syncplay.tsx
Normal file
145
app/(auth)/(tabs)/(home)/syncplay.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { List } from "@/components/List";
|
||||||
|
import { ListItem } from "@/components/ListItem";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ActivityIndicator, Alert, ScrollView, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const name = useMemo(() => user?.Name || "", [user]);
|
||||||
|
|
||||||
|
const { data: activeGroups } = useQuery({
|
||||||
|
queryKey: ["syncplay", "activeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayGetGroups();
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 1000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnMount: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroupMutation = useMutation({
|
||||||
|
mutationFn: async (GroupName: string) => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayCreateGroup({
|
||||||
|
newGroupRequestDto: {
|
||||||
|
GroupName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status !== 204) {
|
||||||
|
Alert.alert("Error", "Failed to create group");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroup = () => {
|
||||||
|
Alert.prompt("Create Group", "Enter a name for the group", (text) => {
|
||||||
|
if (text) {
|
||||||
|
createGroupMutation.mutate(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinGroupMutation = useMutation({
|
||||||
|
mutationFn: async (groupId: string) => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayJoinGroup({
|
||||||
|
joinGroupRequestDto: {
|
||||||
|
GroupId: groupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status !== 204) {
|
||||||
|
Alert.alert("Error", "Failed to join group");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveGroupMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayLeaveGroup();
|
||||||
|
if (res.status !== 204) {
|
||||||
|
Alert.alert("Error", "Failed to exit group");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView>
|
||||||
|
<View className="px-4 py-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold mb-4">Join group</Text>
|
||||||
|
{!activeGroups?.length && (
|
||||||
|
<Text className="text-neutral-500 mb-4">No active groups</Text>
|
||||||
|
)}
|
||||||
|
<List>
|
||||||
|
{activeGroups?.map((group) => (
|
||||||
|
<ListItem
|
||||||
|
key={group.GroupId}
|
||||||
|
title={group.GroupName}
|
||||||
|
onPress={async () => {
|
||||||
|
if (!group.GroupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (group.Participants?.includes(name)) {
|
||||||
|
leaveGroupMutation.mutate();
|
||||||
|
} else {
|
||||||
|
joinGroupMutation.mutate(group.GroupId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
group.Participants?.includes(name) ? (
|
||||||
|
<Ionicons name="exit-outline" size={20} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="add" size={20} color="white" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
subTitle={group.Participants?.join(", ")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ListItem
|
||||||
|
onPress={() => createGroup()}
|
||||||
|
key={"create"}
|
||||||
|
title={"Create group"}
|
||||||
|
iconAfter={
|
||||||
|
createGroupMutation.isPending ? (
|
||||||
|
<ActivityIndicator size={20} color={"white"} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="add" size={20} color="white" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ import { useAtom } from "jotai";
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
|
||||||
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
const { collectionId } = searchParams as { collectionId: string };
|
||||||
@@ -167,7 +169,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
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%",
|
||||||
@@ -191,7 +193,7 @@ const page: React.FC = () => {
|
|||||||
{/* <MoviePoster item={item} /> */}
|
{/* <MoviePoster item={item} /> */}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
|
||||||
return (
|
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
||||||
<>
|
|
||||||
<Stack.Screen options={{ autoHideHomeIndicator: true }} />
|
return memoizedContent;
|
||||||
<ItemContent id={id} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default React.memo(Page);
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
useFocusEffect,
|
|
||||||
useLocalSearchParams,
|
|
||||||
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, useLayoutEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||||
@@ -132,13 +128,6 @@ const Page = () => {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: library?.Name || "",
|
|
||||||
});
|
|
||||||
}, [library]);
|
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
|
|||||||
@@ -195,16 +195,6 @@ export default function IndexLayout() {
|
|||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
|
||||||
name="collections/[collectionId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ export default function SearchLayout() {
|
|||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
|
||||||
name="collections/[collectionId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,9 +278,9 @@ export default function search() {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableItemRouter
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
onPress={() => router.push(`/series/${item.Id}`)}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
@@ -290,7 +290,7 @@ export default function search() {
|
|||||||
<Text className="opacity-50 text-xs">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -302,14 +302,14 @@ export default function search() {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableItemRouter
|
<TouchableOpacity
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
|
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||||
className="flex flex-col w-44"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</TouchableItemRouter>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -321,16 +321,16 @@ export default function search() {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableItemRouter
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
|
onPress={() => router.push(`/collections/${item.Id}`)}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import * as NavigationBar from "expo-navigation-bar";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Platform, View, ViewProps } from "react-native";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate) {
|
|
||||||
// Don't need to do anything
|
|
||||||
} else if (settings?.defaultVideoOrientation) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
NavigationBar.setVisibilityAsync("hidden");
|
|
||||||
NavigationBar.setBehaviorAsync("overlay-swipe");
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (settings?.autoRotate) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
|
||||||
} else {
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
NavigationBar.setVisibilityAsync("visible");
|
|
||||||
NavigationBar.setBehaviorAsync("inset-swipe");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="">
|
|
||||||
<StatusBar hidden />
|
|
||||||
<FullScreenVideoPlayer />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
|
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||||
@@ -19,7 +19,6 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
import { Toaster } from "sonner-native";
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
@@ -107,12 +106,7 @@ function Layout() {
|
|||||||
<PlaybackProvider>
|
<PlaybackProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack
|
<Stack initialRouteName="/home">
|
||||||
initialRouteName="/home"
|
|
||||||
screenOptions={{
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/(tabs)"
|
name="(auth)/(tabs)"
|
||||||
options={{
|
options={{
|
||||||
@@ -120,30 +114,13 @@ function Layout() {
|
|||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/play"
|
|
||||||
options={{ headerShown: false, title: "" }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{ headerShown: false, title: "Login" }}
|
options={{ headerShown: false, title: "Login" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
{/* <FullScreenVideoPlayer /> */}
|
<CurrentlyPlayingBar />
|
||||||
<Toaster
|
|
||||||
duration={2000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PlaybackProvider>
|
</PlaybackProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
|
|||||||
112
app/login.tsx
112
app/login.tsx
@@ -3,8 +3,6 @@ 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 { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useLocalSearchParams } 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";
|
||||||
@@ -35,7 +33,6 @@ const Login: React.FC = () => {
|
|||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
@@ -47,8 +44,6 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
// we might re-use the checkUrl function here to check the url as well
|
|
||||||
// however, I don't think it should be necessary for now
|
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
@@ -84,93 +79,12 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
const handleConnect = (url: string) => {
|
||||||
|
if (!url.startsWith("http")) {
|
||||||
/**
|
Alert.alert("Error", "URL needs to start with http or https.");
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
|
||||||
*
|
|
||||||
* This function attempts to connect to a Jellyfin server using the provided URL.
|
|
||||||
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
|
|
||||||
*
|
|
||||||
* @param {string} url - The base URL of the Jellyfin server to check.
|
|
||||||
* @returns {Promise<string | undefined>} A Promise that resolves to:
|
|
||||||
* - The full URL (including protocol) if a valid Jellyfin server is found.
|
|
||||||
* - undefined if no valid server is found at the given URL.
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
|
||||||
* - Logs errors and timeout information to the console.
|
|
||||||
*/
|
|
||||||
async function checkUrl(url: string) {
|
|
||||||
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
|
||||||
setLoadingServerCheck(true);
|
|
||||||
|
|
||||||
const protocols = ["https://", "http://"];
|
|
||||||
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const protocol of protocols) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
|
|
||||||
mode: "cors",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return `${protocol}${url}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
if (error.name === "AbortError") {
|
|
||||||
console.log(`Request to ${protocol}${url} timed out`);
|
|
||||||
} else {
|
|
||||||
console.error(`Error checking ${protocol}${url}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
setLoadingServerCheck(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
|
||||||
*
|
|
||||||
* This function trims the input URL, checks its validity using the `checkUrl` function,
|
|
||||||
* and sets the server address if a valid connection is established.
|
|
||||||
*
|
|
||||||
* @param {string} url - The URL of the Jellyfin server to connect to.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Calls `checkUrl` to validate the server URL.
|
|
||||||
* - Shows an alert if the connection fails.
|
|
||||||
* - Sets the server address using `setServer` if the connection is successful.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const handleConnect = async (url: string) => {
|
|
||||||
url = url.trim();
|
|
||||||
|
|
||||||
const result = await checkUrl(
|
|
||||||
url.startsWith("http") ? new URL(url).host : url
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === undefined) {
|
|
||||||
Alert.alert(
|
|
||||||
"Connection failed",
|
|
||||||
"Could not connect to the server. Please check the URL and your network connection."
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setServer({ address: url.trim() });
|
||||||
setServer({ address: result });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
@@ -199,9 +113,7 @@ const Login: React.FC = () => {
|
|||||||
<View></View>
|
<View></View>
|
||||||
<View>
|
<View>
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<Text className="text-3xl font-bold mb-1">
|
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
||||||
{serverName || "Streamyfin"}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-neutral-500 mb-2">
|
<Text className="text-neutral-500 mb-2">
|
||||||
Server: {api.basePath}
|
Server: {api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -209,6 +121,7 @@ const Login: React.FC = () => {
|
|||||||
color="black"
|
color="black"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
|
setServerURL("");
|
||||||
}}
|
}}
|
||||||
justify="between"
|
justify="between"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
@@ -225,6 +138,9 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold">Log in</Text>
|
<Text className="text-2xl font-bold">Log in</Text>
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
Log in to any user account
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -302,13 +218,11 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
<Text className="opacity-30">
|
||||||
|
Server URL requires http or https
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||||
loading={loadingServerCheck}
|
|
||||||
disabled={loadingServerCheck}
|
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
|
||||||
className="mb-2"
|
|
||||||
>
|
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
const index = source.DefaultAudioStreamIndex;
|
const index = source.DefaultAudioStreamIndex;
|
||||||
if (index !== undefined && index !== null) {
|
if (index !== undefined && index !== null) {
|
||||||
|
console.log("DefaultAudioStreamIndex", index);
|
||||||
onChange(index);
|
onChange(index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
313
components/CurrentlyPlayingBar.tsx
Normal file
313
components/CurrentlyPlayingBar.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import Video from "react-native-video";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
|
export const CurrentlyPlayingBar: React.FC = () => {
|
||||||
|
const segments = useSegments();
|
||||||
|
const {
|
||||||
|
currentlyPlaying,
|
||||||
|
pauseVideo,
|
||||||
|
playVideo,
|
||||||
|
stopPlayback,
|
||||||
|
setVolume,
|
||||||
|
setIsPlaying,
|
||||||
|
isPlaying,
|
||||||
|
videoRef,
|
||||||
|
presentFullscreenPlayer,
|
||||||
|
onProgress,
|
||||||
|
onBuffer,
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const aBottom = useSharedValue(0);
|
||||||
|
const aPadding = useSharedValue(0);
|
||||||
|
const aHeight = useSharedValue(100);
|
||||||
|
const router = useRouter();
|
||||||
|
const animatedOuterStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
bottom: withTiming(aBottom.value, { duration: 500 }),
|
||||||
|
height: withTiming(aHeight.value, { duration: 500 }),
|
||||||
|
padding: withTiming(aPadding.value, { duration: 500 }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const aPaddingBottom = useSharedValue(30);
|
||||||
|
const aPaddingInner = useSharedValue(12);
|
||||||
|
const aBorderRadiusBottom = useSharedValue(12);
|
||||||
|
const animatedInnerStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
padding: withTiming(aPaddingInner.value, { duration: 500 }),
|
||||||
|
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
|
||||||
|
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
|
||||||
|
duration: 500,
|
||||||
|
}),
|
||||||
|
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
|
||||||
|
duration: 500,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const from = useMemo(() => segments[2], [segments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (segments.find((s) => s.includes("tabs"))) {
|
||||||
|
// Tab screen - i.e. home
|
||||||
|
aBottom.value = Platform.OS === "ios" ? 78 : 50;
|
||||||
|
aHeight.value = 80;
|
||||||
|
aPadding.value = 8;
|
||||||
|
aPaddingBottom.value = 8;
|
||||||
|
aPaddingInner.value = 8;
|
||||||
|
} else {
|
||||||
|
// Inside a normal screen
|
||||||
|
aBottom.value = Platform.OS === "ios" ? 0 : 0;
|
||||||
|
aHeight.value = Platform.OS === "ios" ? 110 : 80;
|
||||||
|
aPadding.value = Platform.OS === "ios" ? 0 : 8;
|
||||||
|
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
|
||||||
|
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
|
||||||
|
}
|
||||||
|
}, [segments]);
|
||||||
|
|
||||||
|
const startPosition = useMemo(
|
||||||
|
() =>
|
||||||
|
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(
|
||||||
|
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
[currentlyPlaying?.item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (currentlyPlaying?.item.Type === "Audio")
|
||||||
|
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
|
||||||
|
else
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: currentlyPlaying?.item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [currentlyPlaying?.item.Id, api]);
|
||||||
|
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!api || !currentlyPlaying || !poster) return null;
|
||||||
|
return {
|
||||||
|
uri: currentlyPlaying.url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: currentlyPlaying.item?.AlbumArtist
|
||||||
|
? currentlyPlaying.item?.AlbumArtist
|
||||||
|
: undefined,
|
||||||
|
title: currentlyPlaying.item?.Name || "Unknown",
|
||||||
|
description: currentlyPlaying.item?.Overview
|
||||||
|
? currentlyPlaying.item?.Overview
|
||||||
|
: undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: currentlyPlaying.item?.Album
|
||||||
|
? currentlyPlaying.item?.Album
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [currentlyPlaying, startPosition, api, poster]);
|
||||||
|
|
||||||
|
if (!api || !currentlyPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[animatedOuterStyle]}
|
||||||
|
className="absolute left-0 w-screen"
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === "android" ? 60 : 100}
|
||||||
|
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
|
||||||
|
className={`h-full w-full rounded-xl overflow-hidden ${
|
||||||
|
Platform.OS === "android" && "bg-black"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
|
||||||
|
animatedInnerStyle,
|
||||||
|
]}
|
||||||
|
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-4 shrink">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
|
}}
|
||||||
|
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||||
|
${
|
||||||
|
currentlyPlaying.item?.Type === "Audio"
|
||||||
|
? "aspect-square"
|
||||||
|
: "aspect-video"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{videoSource && (
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
allowsExternalPlayback
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
playWhenInactive={true}
|
||||||
|
playInBackground={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
controls={false}
|
||||||
|
pictureInPicture={true}
|
||||||
|
poster={
|
||||||
|
poster && currentlyPlaying.item?.Type === "Audio"
|
||||||
|
? poster
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
debug={{
|
||||||
|
enable: true,
|
||||||
|
thread: true,
|
||||||
|
}}
|
||||||
|
onIdle={() => {
|
||||||
|
console.log("IDLE");
|
||||||
|
}}
|
||||||
|
fullscreenAutorotate={true}
|
||||||
|
onReadyForDisplay={() => {
|
||||||
|
console.log("READY FOR DISPLAY");
|
||||||
|
}}
|
||||||
|
onProgress={(e) => onProgress(e)}
|
||||||
|
subtitleStyle={{
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
onBuffer={(e) => onBuffer(e.isBuffering)}
|
||||||
|
source={videoSource}
|
||||||
|
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
}, 300);
|
||||||
|
}}
|
||||||
|
onPlaybackStateChanged={(e) => {
|
||||||
|
if (e.isPlaying === true) {
|
||||||
|
playVideo(false);
|
||||||
|
} else if (e.isPlaying === false) {
|
||||||
|
pauseVideo(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onVolumeChange={(e) => {
|
||||||
|
setVolume(e.volume);
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={4000}
|
||||||
|
onError={(e) => {
|
||||||
|
console.log(e);
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
"Video playback error: " + JSON.stringify(e)
|
||||||
|
);
|
||||||
|
Alert.alert("Error", "Cannot play this video file.");
|
||||||
|
setIsPlaying(false);
|
||||||
|
// setCurrentlyPlaying(null);
|
||||||
|
}}
|
||||||
|
renderLoader={
|
||||||
|
currentlyPlaying.item?.Type !== "Audio" && (
|
||||||
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className="shrink text-xs">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (currentlyPlaying.item?.Type === "Audio") {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{currentlyPlaying.item?.Type === "Episode" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="text-xs opacity-50"
|
||||||
|
>
|
||||||
|
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Movie" && (
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Audio" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.Album}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (isPlaying) pauseVideo();
|
||||||
|
else playVideo();
|
||||||
|
}}
|
||||||
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Ionicons name="pause" size={24} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="play" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
stopPlayback();
|
||||||
|
}}
|
||||||
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -126,6 +126,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay) {
|
if (mediaSource.SupportsDirectPlay) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
|
console.log("Using direct stream for video!");
|
||||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
console.log("Using direct stream for audio!");
|
console.log("Using direct stream for audio!");
|
||||||
@@ -148,6 +149,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
}/universal?${searchParams.toString()}`;
|
}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
} else if (mediaSource.TranscodingUrl) {
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
|
console.log("Using transcoded stream!");
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,643 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
TouchableOpacity,
|
|
||||||
Alert,
|
|
||||||
Dimensions,
|
|
||||||
BackHandler,
|
|
||||||
Pressable,
|
|
||||||
Touchable,
|
|
||||||
} from "react-native";
|
|
||||||
import Video, { OnProgressData } from "react-native-video";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
|
||||||
import { itemRouter } from "./common/TouchableItemRouter";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedReaction,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import {
|
|
||||||
useSafeAreaFrame,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from "react-native-safe-area-context";
|
|
||||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
|
||||||
import {
|
|
||||||
formatTimeString,
|
|
||||||
runtimeTicksToSeconds,
|
|
||||||
ticksToSeconds,
|
|
||||||
} from "@/utils/time";
|
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
|
||||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
|
||||||
|
|
||||||
const windowDimensions = Dimensions.get("window");
|
|
||||||
const screenDimensions = Dimensions.get("screen");
|
|
||||||
|
|
||||||
export const FullScreenVideoPlayer: React.FC = () => {
|
|
||||||
const {
|
|
||||||
currentlyPlaying,
|
|
||||||
pauseVideo,
|
|
||||||
playVideo,
|
|
||||||
stopPlayback,
|
|
||||||
setVolume,
|
|
||||||
setIsPlaying,
|
|
||||||
isPlaying,
|
|
||||||
videoRef,
|
|
||||||
onProgress,
|
|
||||||
setIsBuffering,
|
|
||||||
} = usePlayback();
|
|
||||||
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const router = useRouter();
|
|
||||||
const segments = useSegments();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
|
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
|
|
||||||
useTrickplay(currentlyPlaying);
|
|
||||||
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [isBuffering, setIsBufferingState] = useState(true);
|
|
||||||
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
|
|
||||||
const [isStatusBarHidden, setIsStatusBarHidden] = useState(false);
|
|
||||||
|
|
||||||
// Seconds
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [remainingTime, setRemainingTime] = useState(0);
|
|
||||||
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const min = useSharedValue(0);
|
|
||||||
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
|
|
||||||
|
|
||||||
const [dimensions, setDimensions] = useState({
|
|
||||||
window: windowDimensions,
|
|
||||||
screen: screenDimensions,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = Dimensions.addEventListener(
|
|
||||||
"change",
|
|
||||||
({ window, screen }) => {
|
|
||||||
setDimensions({ window, screen });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return () => subscription?.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const from = useMemo(() => segments[2], [segments]);
|
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
|
||||||
(currentProgress: number, maxValue: number) => {
|
|
||||||
const current = ticksToSeconds(currentProgress);
|
|
||||||
const remaining = ticksToSeconds(maxValue - current);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
|
||||||
setRemainingTime(remaining);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
|
||||||
currentlyPlaying?.item.Id,
|
|
||||||
currentTime,
|
|
||||||
videoRef
|
|
||||||
);
|
|
||||||
|
|
||||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
|
||||||
currentlyPlaying?.item.Id,
|
|
||||||
currentTime,
|
|
||||||
videoRef
|
|
||||||
);
|
|
||||||
|
|
||||||
useAnimatedReaction(
|
|
||||||
() => ({
|
|
||||||
progress: progress.value,
|
|
||||||
max: max.value,
|
|
||||||
isSeeking: isSeeking.value,
|
|
||||||
}),
|
|
||||||
(result) => {
|
|
||||||
if (result.isSeeking === false) {
|
|
||||||
runOnJS(updateTimes)(result.progress, result.max);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updateTimes]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const backAction = () => {
|
|
||||||
if (currentlyPlaying) {
|
|
||||||
Alert.alert("Hold on!", "Are you sure you want to exit?", [
|
|
||||||
{
|
|
||||||
text: "Cancel",
|
|
||||||
onPress: () => null,
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Yes",
|
|
||||||
onPress: () => {
|
|
||||||
stopPlayback();
|
|
||||||
router.back();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const backHandler = BackHandler.addEventListener(
|
|
||||||
"hardwareBackPress",
|
|
||||||
backAction
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => backHandler.remove();
|
|
||||||
}, [currentlyPlaying, stopPlayback, router]);
|
|
||||||
|
|
||||||
const [orientation, setOrientation] = useState(
|
|
||||||
ScreenOrientation.OrientationLock.UNKNOWN
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event listener for orientation
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
|
||||||
(event) => {
|
|
||||||
setOrientation(
|
|
||||||
orientationToOrientationLock(event.orientationInfo.orientation)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
|
||||||
setOrientation(orientationToOrientationLock(orientation));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isLandscape = useMemo(() => {
|
|
||||||
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
|
|
||||||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
|
||||||
? true
|
|
||||||
: false;
|
|
||||||
}, [orientation]);
|
|
||||||
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!currentlyPlaying?.item || !api) return "";
|
|
||||||
return currentlyPlaying.item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: currentlyPlaying.item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [currentlyPlaying?.item, api]);
|
|
||||||
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!api || !currentlyPlaying || !poster) return null;
|
|
||||||
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
return {
|
|
||||||
uri: currentlyPlaying.url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
|
|
||||||
title: currentlyPlaying.item?.Name || "Unknown",
|
|
||||||
description: currentlyPlaying.item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: currentlyPlaying.item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [currentlyPlaying, api, poster]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentlyPlaying) {
|
|
||||||
progress.value =
|
|
||||||
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
|
|
||||||
max.value = currentlyPlaying.item.RunTimeTicks || 0;
|
|
||||||
setShowControls(true);
|
|
||||||
}
|
|
||||||
}, [currentlyPlaying]);
|
|
||||||
|
|
||||||
const toggleControls = () => setShowControls(!showControls);
|
|
||||||
|
|
||||||
const handleVideoProgress = useCallback(
|
|
||||||
(data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBufferingState(data.playableDuration === 0);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
onProgress(data);
|
|
||||||
},
|
|
||||||
[onProgress, setIsBuffering, isSeeking]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleVideoError = useCallback(
|
|
||||||
(e: any) => {
|
|
||||||
console.log(e);
|
|
||||||
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
|
|
||||||
Alert.alert("Error", "Cannot play this video file.");
|
|
||||||
setIsPlaying(false);
|
|
||||||
},
|
|
||||||
[setIsPlaying]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePlayPause = useCallback(() => {
|
|
||||||
if (isPlaying) pauseVideo();
|
|
||||||
else playVideo();
|
|
||||||
}, [isPlaying, pauseVideo, playVideo]);
|
|
||||||
|
|
||||||
const handleSliderComplete = (value: number) => {
|
|
||||||
progress.value = value;
|
|
||||||
isSeeking.value = false;
|
|
||||||
videoRef.current?.seek(value / 10000000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSliderChange = (value: number) => {
|
|
||||||
calculateTrickplayUrl(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSliderStart = useCallback(() => {
|
|
||||||
if (showControls === false) return;
|
|
||||||
isSeeking.value = true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSkipBackward = useCallback(async () => {
|
|
||||||
if (!settings) return;
|
|
||||||
try {
|
|
||||||
const curr = await videoRef.current?.getCurrentPosition();
|
|
||||||
if (curr !== undefined) {
|
|
||||||
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
|
||||||
}
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
|
||||||
if (!settings) return;
|
|
||||||
try {
|
|
||||||
const curr = await videoRef.current?.getCurrentPosition();
|
|
||||||
if (curr !== undefined) {
|
|
||||||
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
|
||||||
}
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const handleGoToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem || !from) return;
|
|
||||||
const url = itemRouter(previousItem, from);
|
|
||||||
stopPlayback();
|
|
||||||
// @ts-ignore
|
|
||||||
router.push(url);
|
|
||||||
}, [previousItem, from, stopPlayback, router]);
|
|
||||||
|
|
||||||
const handleGoToNextItem = useCallback(() => {
|
|
||||||
if (!nextItem || !from) return;
|
|
||||||
const url = itemRouter(nextItem, from);
|
|
||||||
stopPlayback();
|
|
||||||
// @ts-ignore
|
|
||||||
router.push(url);
|
|
||||||
}, [nextItem, from, stopPlayback, router]);
|
|
||||||
|
|
||||||
const toggleIgnoreSafeArea = useCallback(() => {
|
|
||||||
setIgnoreSafeArea((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!currentlyPlaying) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: dimensions.window.width,
|
|
||||||
height: dimensions.window.height,
|
|
||||||
backgroundColor: "black",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={toggleControls}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: ignoreSafeArea ? 0 : insets.left,
|
|
||||||
right: ignoreSafeArea ? 0 : insets.right,
|
|
||||||
width: ignoreSafeArea
|
|
||||||
? dimensions.window.width
|
|
||||||
: dimensions.window.width - (insets.left + insets.right),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{videoSource && (
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeArea ? "cover" : "contain"}
|
|
||||||
onProgress={handleVideoProgress}
|
|
||||||
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
|
|
||||||
onError={handleVideoError}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{(showControls || isBuffering) && (
|
|
||||||
<View
|
|
||||||
pointerEvents="none"
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
position: "absolute",
|
|
||||||
width: dimensions.window.width,
|
|
||||||
height: dimensions.window.height,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className=" bg-black/50 z-0"
|
|
||||||
></View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBuffering && (
|
|
||||||
<View
|
|
||||||
pointerEvents="none"
|
|
||||||
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSkipButton && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
|
|
||||||
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
|
||||||
height: 70,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className="z-10"
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={skipIntro}
|
|
||||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
|
||||||
>
|
|
||||||
<Text className="text-white">Skip Intro</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSkipCreditButton && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
|
|
||||||
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
|
||||||
height: 70,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className="z-10"
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={skipCredit}
|
|
||||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
|
||||||
>
|
|
||||||
<Text className="text-white">Skip Credits</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showControls && (
|
|
||||||
<>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: insets.top,
|
|
||||||
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
|
||||||
height: 70,
|
|
||||||
zIndex: 10,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className="flex flex-row items-center space-x-2 z-10"
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={toggleIgnoreSafeArea}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={ignoreSafeArea ? "contract-outline" : "expand"}
|
|
||||||
size={24}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
stopPlayback();
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insets.bottom + 8,
|
|
||||||
left: isLandscape ? insets.left + 32 : insets.left + 16,
|
|
||||||
width: isLandscape
|
|
||||||
? dimensions.window.width - insets.left - insets.right - 64
|
|
||||||
: dimensions.window.width - insets.left - insets.right - 32,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
|
||||||
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
|
|
||||||
{currentlyPlaying.item?.Type === "Episode" && (
|
|
||||||
<Text className="opacity-50">
|
|
||||||
{currentlyPlaying.item.SeriesName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{currentlyPlaying.item?.Type === "Movie" && (
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{currentlyPlaying.item?.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{currentlyPlaying.item?.Type === "Audio" && (
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{currentlyPlaying.item?.Album}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`flex ${
|
|
||||||
isLandscape
|
|
||||||
? "flex-row space-x-6 py-2 px-4 rounded-full"
|
|
||||||
: "flex-col-reverse py-4 px-4 rounded-2xl"
|
|
||||||
}
|
|
||||||
items-center bg-neutral-800`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-row items-center space-x-4">
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
opacity: !previousItem ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
onPress={handleGoToPreviousItem}
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
|
||||||
<Ionicons
|
|
||||||
name="refresh-outline"
|
|
||||||
size={26}
|
|
||||||
color="white"
|
|
||||||
style={{
|
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handlePlayPause}>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={30}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
|
||||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
opacity: !nextItem ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
onPress={handleGoToNextItem}
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`flex flex-col w-full shrink
|
|
||||||
${""}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
|
||||||
minimumTrackTintColor: "#fff",
|
|
||||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
|
||||||
bubbleBackgroundColor: "#fff",
|
|
||||||
bubbleTextColor: "#000",
|
|
||||||
heartbeatColor: "#999",
|
|
||||||
}}
|
|
||||||
cache={cacheProgress}
|
|
||||||
onSlidingStart={handleSliderStart}
|
|
||||||
onSlidingComplete={handleSliderComplete}
|
|
||||||
onValueChange={handleSliderChange}
|
|
||||||
containerStyle={{
|
|
||||||
borderRadius: 100,
|
|
||||||
}}
|
|
||||||
renderBubble={() => {
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
|
|
||||||
const tileWidth = 150;
|
|
||||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
marginLeft: -tileWidth / 4,
|
|
||||||
marginTop: -tileHeight / 4 - 60,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
className=" bg-neutral-800 overflow-hidden"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
style={{
|
|
||||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
|
||||||
height:
|
|
||||||
(150 / trickplayInfo.aspectRatio!) *
|
|
||||||
trickplayInfo?.data.TileHeight!,
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
sliderHeight={10}
|
|
||||||
thumbWidth={0}
|
|
||||||
progress={progress}
|
|
||||||
minimumValue={min}
|
|
||||||
maximumValue={max}
|
|
||||||
/>
|
|
||||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
{formatTimeString(currentTime)}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
-{formatTimeString(remainingTime)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// GenreTags.tsx
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface GenreTagsProps {
|
|
||||||
genres?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
|
||||||
if (!genres || genres.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-row flex-wrap mt-2">
|
|
||||||
{genres.map((genre) => (
|
|
||||||
<View
|
|
||||||
key={genre}
|
|
||||||
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
|
|
||||||
>
|
|
||||||
<Text className="text-xs">{genre}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -26,7 +26,7 @@ import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { Stack, useNavigation } from "expo-router";
|
import { 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, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -43,7 +43,6 @@ import { Chromecast } from "./Chromecast";
|
|||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -57,7 +56,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
useState<MediaSourceInfo | null>(null);
|
useState<MediaSourceInfo | null>(null);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(-1);
|
useState<number>(0);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -118,8 +117,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
itemId: id,
|
itemId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("itemID", res?.Id);
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
enabled: !!id && !!api,
|
enabled: !!id && !!api,
|
||||||
@@ -363,19 +360,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
{item?.People && item.People.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
{item.People.slice(0, 3).map((person) => (
|
|
||||||
<MoreMoviesWithActor
|
|
||||||
currentItem={item}
|
|
||||||
key={person.Id}
|
|
||||||
actorId={person.Id!}
|
|
||||||
className="mb-4"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { GenreTags } from "./GenreTags";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -13,7 +12,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
if (!item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex flex-col space-y-1.5 w-full items-start h-32"
|
className="flex flex-col space-y-1.5 w-full items-start h-24"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||||
@@ -24,22 +23,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View
|
||||||
<View className="flex flex-col" {...props}>
|
style={{
|
||||||
<Ratings item={item} className="mb-2" />
|
minHeight: 96,
|
||||||
{item.Type === "Episode" && (
|
}}
|
||||||
<>
|
className="flex flex-col"
|
||||||
<EpisodeTitleHeader item={item} />
|
{...props}
|
||||||
<GenreTags genres={item.Genres!} />
|
>
|
||||||
</>
|
<Ratings item={item} className="mb-2" />
|
||||||
)}
|
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
|
||||||
{item.Type === "Movie" && (
|
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
|
||||||
<>
|
|
||||||
<MoviesTitleHeader item={item} />
|
|
||||||
<GenreTags genres={item.Genres!} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
19
components/List.tsx
Normal file
19
components/List.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const List: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
title?: string | null | undefined;
|
title?: string | null | undefined;
|
||||||
subTitle?: string | null | undefined;
|
subTitle?: string | null | undefined;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -17,7 +22,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<TouchableOpacity
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -26,6 +31,6 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{iconAfter}
|
{iconAfter}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
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;
|
||||||
@@ -79,9 +78,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
{name(source.Name)}
|
||||||
source.Size
|
|
||||||
)}`}
|
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
actorId: string;
|
|
||||||
currentItem: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
|
||||||
actorId,
|
|
||||||
currentItem,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data: actor } = useQuery({
|
|
||||||
queryKey: ["actor", actorId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return null;
|
|
||||||
return await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: actorId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!actorId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: items, isLoading } = useQuery({
|
|
||||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
personIds: [actorId],
|
|
||||||
limit: 20,
|
|
||||||
sortOrder: ["Descending"],
|
|
||||||
includeItemTypes: ["Movie", "Series"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
|
||||||
sortBy: ["PremiereDate"],
|
|
||||||
collapseBoxSetItems: false,
|
|
||||||
excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove duplicates based on item ID
|
|
||||||
const uniqueItems =
|
|
||||||
response.data.Items?.reduce((acc, current) => {
|
|
||||||
const x = acc.find((item) => item.Id === current.Id);
|
|
||||||
if (!x) {
|
|
||||||
return acc.concat([current]);
|
|
||||||
} else {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
}, [] as BaseItemDto[]) || [];
|
|
||||||
|
|
||||||
return uniqueItems;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!actorId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items?.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<Text className="text-lg font-bold mb-2 px-4">
|
|
||||||
More with {actor?.Name}
|
|
||||||
</Text>
|
|
||||||
<HorizontalScroll
|
|
||||||
data={items}
|
|
||||||
loading={isLoading}
|
|
||||||
height={247}
|
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={idx}
|
|
||||||
item={item}
|
|
||||||
className="flex flex-col w-28"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -19,7 +19,7 @@ export const OverviewText: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">Overview</Text>
|
<Text className="text-xl font-bold mb-2">Overview</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) =>
|
setLimit((prev) =>
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { BlurView } from "expo-blur";
|
|
||||||
import React from "react";
|
|
||||||
import { Platform, View, ViewProps } from "react-native";
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
blurAmount?: number;
|
|
||||||
blurType?: "light" | "dark" | "xlight";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BlurView for iOS and simple View for Android
|
|
||||||
*/
|
|
||||||
export const PlatformBlurView: React.FC<Props> = ({
|
|
||||||
blurAmount = 100,
|
|
||||||
blurType = "light",
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<BlurView style={style} intensity={blurAmount} {...props}>
|
|
||||||
{children}
|
|
||||||
</BlurView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[{ backgroundColor: "rgba(50, 50, 50, 0.9)" }, style]}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -27,7 +27,6 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -46,8 +45,6 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||||
|
|
||||||
@@ -60,13 +57,12 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
|
|
||||||
const directStream = useMemo(() => {
|
const directStream = useMemo(() => {
|
||||||
return !url?.includes("m3u8");
|
return !url?.includes("m3u8");
|
||||||
}, [url]);
|
}, []);
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!url || !item) return;
|
if (!url || !item) return;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setCurrentlyPlayingState({ item, url });
|
setCurrentlyPlayingState({ item, url });
|
||||||
router.push("/play");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
const options = ["Chromecast", "Device", "Cancel"];
|
||||||
@@ -163,9 +159,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
console.log("Device");
|
|
||||||
setCurrentlyPlayingState({ item, url });
|
setCurrentlyPlayingState({ item, url });
|
||||||
router.push("/play");
|
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
@@ -232,7 +226,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary]
|
[startColor.value.average, endColor.value.average]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -285,7 +279,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[animatedAverageStyle]}
|
||||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { ItemCardText } from "./ItemCardText";
|
import { ItemCardText } from "./ItemCardText";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
|
||||||
|
|
||||||
interface SimilarItemsProps extends ViewProps {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
@@ -48,24 +46,29 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
||||||
<HorizontalScroll
|
{isLoading ? (
|
||||||
data={movies}
|
<View className="my-12">
|
||||||
loading={isLoading}
|
<Loader />
|
||||||
height={247}
|
</View>
|
||||||
noItemsText="No similar items found"
|
) : (
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
<ScrollView horizontal>
|
||||||
<TouchableItemRouter
|
<View className="px-4 flex flex-row gap-x-2">
|
||||||
key={idx}
|
{movies.map((item) => (
|
||||||
item={item}
|
<TouchableOpacity
|
||||||
className="flex flex-col w-28"
|
key={item.Id}
|
||||||
>
|
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||||
<View>
|
className="flex flex-col w-32"
|
||||||
<MoviePoster item={item} />
|
>
|
||||||
<ItemCardText item={item} />
|
<MoviePoster item={item} />
|
||||||
</View>
|
<ItemCardText item={item} />
|
||||||
</TouchableItemRouter>
|
</TouchableOpacity>
|
||||||
)}
|
))}
|
||||||
/>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
{movies.length === 0 && (
|
||||||
|
<Text className="px-4 text-neutral-500">No similar items</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -22,7 +21,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<BlurView
|
<BlurView
|
||||||
{...props}
|
{...props}
|
||||||
@@ -53,7 +52,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
className="drop-shadow-2xl"
|
className="drop-shadow-2xl"
|
||||||
name="arrow-back"
|
name="arrow-back"
|
||||||
size={24}
|
size={24}
|
||||||
color="white"
|
color="#077DF2"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface HorizontalScrollProps<T>
|
|||||||
height?: number;
|
height?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
extraData?: any;
|
extraData?: any;
|
||||||
noItemsText?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HorizontalScroll = forwardRef<
|
export const HorizontalScroll = forwardRef<
|
||||||
@@ -39,7 +38,6 @@ export const HorizontalScroll = forwardRef<
|
|||||||
loading = false,
|
loading = false,
|
||||||
height = 164,
|
height = 164,
|
||||||
extraData,
|
extraData,
|
||||||
noItemsText,
|
|
||||||
...props
|
...props
|
||||||
}: HorizontalScrollProps<T>,
|
}: HorizontalScrollProps<T>,
|
||||||
ref: React.ForwardedRef<HorizontalScrollRef>
|
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||||
@@ -93,9 +91,7 @@ export const HorizontalScroll = forwardRef<
|
|||||||
}}
|
}}
|
||||||
ListEmptyComponent={() => (
|
ListEmptyComponent={() => (
|
||||||
<View className="flex-1 justify-center items-center">
|
<View className="flex-1 justify-center items-center">
|
||||||
<Text className="text-center text-gray-500">
|
<Text className="text-center text-gray-500">No data available</Text>
|
||||||
{noItemsText || "No data available"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,42 +8,6 @@ interface Props extends TouchableOpacityProps {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
|
||||||
if (item.Type === "Series") {
|
|
||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicAlbum") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Audio") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicArtist") {
|
|
||||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Person") {
|
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "BoxSet") {
|
|
||||||
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "UserView") {
|
|
||||||
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "CollectionFolder") {
|
|
||||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
item,
|
item,
|
||||||
children,
|
children,
|
||||||
@@ -59,9 +23,54 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
const url = itemRouter(item, from);
|
|
||||||
// @ts-ignore
|
if (item.Type === "Series") {
|
||||||
router.push(url);
|
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "MusicAlbum") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Audio") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "MusicArtist") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Person") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "BoxSet") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "UserView") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "CollectionFolder") {
|
||||||
|
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same as default
|
||||||
|
// if (item.Type === "Episode") {
|
||||||
|
// router.push(`/items/${item.Id}`);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { Text } from "../common/Text";
|
|||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -23,7 +22,6 @@ interface EpisodeCardProps {
|
|||||||
*/
|
*/
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useFiles();
|
const { deleteFile } = useFiles();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { startDownloadedFilePlayback } = usePlayback();
|
const { startDownloadedFilePlayback } = usePlayback();
|
||||||
|
|
||||||
@@ -32,7 +30,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
item,
|
item,
|
||||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||||
});
|
});
|
||||||
router.push("/play");
|
|
||||||
}, [item, startDownloadedFilePlayback]);
|
}, [item, startDownloadedFilePlayback]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -23,7 +24,8 @@ interface MovieCardProps {
|
|||||||
*/
|
*/
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useFiles();
|
const { deleteFile } = useFiles();
|
||||||
const router = useRouter();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const { startDownloadedFilePlayback } = usePlayback();
|
const { startDownloadedFilePlayback } = usePlayback();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
@@ -31,7 +33,6 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
item,
|
item,
|
||||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||||
});
|
});
|
||||||
router.push("/play");
|
|
||||||
}, [item, startDownloadedFilePlayback]);
|
}, [item, startDownloadedFilePlayback]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,29 +4,25 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Dimensions, View, ViewProps } from "react-native";
|
||||||
import Animated, {
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
runOnJS,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import Carousel, {
|
import Carousel, {
|
||||||
ICarouselInstance,
|
ICarouselInstance,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from "react-native-reanimated-carousel";
|
} from "react-native-reanimated-carousel";
|
||||||
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const ref = React.useRef<ICarouselInstance>(null);
|
const ref = React.useRef<ICarouselInstance>(null);
|
||||||
@@ -126,7 +122,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const router = useRouter();
|
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
@@ -145,41 +141,11 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
return getLogoImageUrlById({ api, item, height: 100 });
|
return getLogoImageUrlById({ api, item, height: 100 });
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const segments = useSegments();
|
|
||||||
const from = segments[2];
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
|
|
||||||
const handleRoute = useCallback(() => {
|
|
||||||
if (!from) return;
|
|
||||||
const url = itemRouter(item, from);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
// @ts-ignore
|
|
||||||
if (url) router.push(url);
|
|
||||||
}, [item, from]);
|
|
||||||
|
|
||||||
const tap = Gesture.Tap()
|
|
||||||
.maxDuration(2000)
|
|
||||||
.onBegin(() => {
|
|
||||||
opacity.value = withTiming(0.5, { duration: 100 });
|
|
||||||
})
|
|
||||||
.onEnd(() => {
|
|
||||||
runOnJS(handleRoute)();
|
|
||||||
})
|
|
||||||
.onFinalize(() => {
|
|
||||||
opacity.value = withTiming(1, { duration: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uri || !logoUri) return null;
|
if (!uri || !logoUri) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={tap}>
|
<TouchableItemRouter item={item}>
|
||||||
<Animated.View
|
<View className="px-4">
|
||||||
style={{
|
|
||||||
opacity: opacity,
|
|
||||||
}}
|
|
||||||
className="px-4"
|
|
||||||
>
|
|
||||||
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
@@ -205,7 +171,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
</GestureDetector>
|
</TouchableItemRouter>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
{library.Name}
|
{library.Name}
|
||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
|
||||||
{itemsCount} items
|
{itemsCount} items
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ interface Props extends ViewProps {
|
|||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className=" font-bold text-2xl mb-1" selectable>
|
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
|
||||||
{item?.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
loading={loading}
|
loading={loading}
|
||||||
height={247}
|
|
||||||
data={item?.People || []}
|
data={item?.People || []}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -33,7 +32,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
router.push(`/actors/${item.Id}`);
|
router.push(`/actors/${item.Id}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-32"
|
||||||
>
|
>
|
||||||
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
||||||
<Text className="mt-2">{item.Name}</Text>
|
<Text className="mt-2">{item.Name}</Text>
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={[item]}
|
data={[item]}
|
||||||
height={247}
|
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/series/${item.SeriesId}`)}
|
onPress={() => router.push(`/series/${item.SeriesId}`)}
|
||||||
className="flex flex-col space-y-2 w-28"
|
className="flex flex-col space-y-2 w-32"
|
||||||
>
|
>
|
||||||
<Poster
|
<Poster
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="font-bold text-2xl" selectable>
|
<Text className="font-bold text-2xl">{item?.Name}</Text>
|
||||||
{item?.Name}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row items-center mb-1">
|
<View className="flex flex-row items-center mb-1">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
|
||||||
|
|
||||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -47,14 +46,16 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableItemRouter
|
<TouchableOpacity
|
||||||
item={item}
|
onPress={() => {
|
||||||
key={index}
|
router.push(`/(auth)/items/page?id=${item.Id}`);
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
className="flex flex-col w-44"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} useEpisodePoster />
|
<ContinueWatchingPoster item={item} useEpisodePoster />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</TouchableItemRouter>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -193,9 +192,11 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
episodes?.map((e: BaseItemDto) => (
|
episodes?.map((e: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableOpacity
|
||||||
item={e}
|
|
||||||
key={e.Id}
|
key={e.Id}
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/(auth)/items/page?id=${e.Id}`);
|
||||||
|
}}
|
||||||
className="flex flex-col mb-4"
|
className="flex flex-col mb-4"
|
||||||
>
|
>
|
||||||
<View className="flex flex-row items-center mb-2">
|
<View className="flex flex-row items-center mb-2">
|
||||||
@@ -228,7 +229,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
>
|
>
|
||||||
{e.Overview}
|
{e.Overview}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableOpacity>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,16 +1,39 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import {
|
||||||
|
DefaultLanguageOption,
|
||||||
|
DownloadOptions,
|
||||||
|
useSettings,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { LANGUAGES } from "@/constants/Languages";
|
import { Loader } from "../Loader";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
const LANGUAGES: DefaultLanguageOption[] = [
|
||||||
|
{ label: "eng", value: "eng" },
|
||||||
|
{
|
||||||
|
label: "sv",
|
||||||
|
value: "sv",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||||
@@ -121,82 +144,6 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Forward skip length</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose length in seconds when skipping in video playback.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
updateSettings({
|
|
||||||
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Text>-</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
|
||||||
{settings.forwardSkipTime}s
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
|
||||||
onPress={() =>
|
|
||||||
updateSettings({
|
|
||||||
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Rewind length</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose length in seconds when skipping in video playback.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
updateSettings({
|
|
||||||
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Text>-</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
|
||||||
{settings.rewindSkipTime}s
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
|
||||||
onPress={() =>
|
|
||||||
updateSettings({
|
|
||||||
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,19 +2,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import {
|
import {
|
||||||
DefaultLanguageOption,
|
DefaultLanguageOption,
|
||||||
DownloadOptions,
|
DownloadOptions,
|
||||||
ScreenOrientationEnum,
|
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
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 {
|
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||||
Linking,
|
|
||||||
Switch,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
@@ -22,11 +15,8 @@ import { Input } from "../common/Input";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
export const SettingToggles: React.FC = () => {
|
||||||
|
|
||||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -58,10 +48,8 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View>
|
||||||
{/* <View>
|
{/* <View>
|
||||||
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
||||||
@@ -82,7 +70,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl 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 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="shrink">
|
<View className="shrink">
|
||||||
<Text className="font-semibold">Auto rotate</Text>
|
<Text className="font-semibold">Auto rotate</Text>
|
||||||
@@ -92,116 +80,25 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.autoRotate}
|
value={settings?.autoRotate}
|
||||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
<View className="shrink">
|
||||||
className={`
|
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||||
${
|
|
||||||
settings.autoRotate
|
|
||||||
? "opacity-50 pointer-events-none"
|
|
||||||
: "opacity-100"
|
|
||||||
}
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Video orientation</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Set the full screen video player orientation.
|
Clicking a video will start it in fullscreen mode, instead of
|
||||||
|
inline.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<Switch
|
||||||
<DropdownMenu.Trigger>
|
value={settings?.openFullScreenVideoPlayerByDefault}
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
onValueChange={(value) =>
|
||||||
<Text>
|
updateSettings({ openFullScreenVideoPlayerByDefault: value })
|
||||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
}
|
||||||
</Text>
|
/>
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.DEFAULT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.DEFAULT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="4"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
@@ -213,7 +110,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.openInVLC}
|
value={settings?.openInVLC}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||||
}}
|
}}
|
||||||
@@ -236,13 +133,13 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.usePopularPlugin}
|
value={settings?.usePopularPlugin}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ usePopularPlugin: value })
|
updateSettings({ usePopularPlugin: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{settings.usePopularPlugin && (
|
{settings?.usePopularPlugin && (
|
||||||
<View className="flex flex-col py-2 bg-neutral-900">
|
<View className="flex flex-col py-2 bg-neutral-900">
|
||||||
{mediaListCollections?.map((mlc) => (
|
{mediaListCollections?.map((mlc) => (
|
||||||
<View
|
<View
|
||||||
@@ -253,7 +150,9 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<Text className="font-semibold">{mlc.Name}</Text>
|
<Text className="font-semibold">{mlc.Name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
value={settings?.mediaListCollectionIds?.includes(
|
||||||
|
mlc.Id!
|
||||||
|
)}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!settings.mediaListCollectionIds) {
|
if (!settings.mediaListCollectionIds) {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -264,11 +163,11 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
mediaListCollectionIds:
|
mediaListCollectionIds:
|
||||||
settings.mediaListCollectionIds.includes(mlc.Id!)
|
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
||||||
? settings.mediaListCollectionIds.filter(
|
? settings?.mediaListCollectionIds.filter(
|
||||||
(id) => id !== mlc.Id
|
(id) => id !== mlc.Id
|
||||||
)
|
)
|
||||||
: [...settings.mediaListCollectionIds, mlc.Id!],
|
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -299,7 +198,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.forceDirectPlay}
|
value={settings?.forceDirectPlay}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ forceDirectPlay: value })
|
updateSettings({ forceDirectPlay: value })
|
||||||
}
|
}
|
||||||
@@ -309,7 +208,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
@@ -322,7 +221,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity 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">
|
||||||
<Text>{settings.deviceProfile}</Text>
|
<Text>{settings?.deviceProfile}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -365,8 +264,8 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
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>
|
||||||
@@ -377,7 +276,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity 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">
|
||||||
<Text>{settings.searchEngine}</Text>
|
<Text>{settings?.searchEngine}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -411,7 +310,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</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">
|
||||||
@@ -439,7 +338,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="text-neutral-500 mt-2">
|
<Text className="text-neutral-500 mt-2">
|
||||||
{settings.marlinServerUrl}
|
{settings?.marlinServerUrl}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const routes = [
|
|||||||
"albums/[albumId]",
|
"albums/[albumId]",
|
||||||
"artists/index",
|
"artists/index",
|
||||||
"artists/[artistId]",
|
"artists/[artistId]",
|
||||||
|
"collections/[collectionId]",
|
||||||
"items/page",
|
"items/page",
|
||||||
"series/[id]",
|
"series/[id]",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { DefaultLanguageOption } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export const LANGUAGES: DefaultLanguageOption[] = [
|
|
||||||
{ label: "English", value: "eng" },
|
|
||||||
{ label: "Spanish", value: "es" },
|
|
||||||
{ label: "Chinese (Mandarin)", value: "zh" },
|
|
||||||
{ label: "Hindi", value: "hi" },
|
|
||||||
{ label: "Arabic", value: "ar" },
|
|
||||||
{ label: "French", value: "fr" },
|
|
||||||
{ label: "Russian", value: "ru" },
|
|
||||||
{ label: "Portuguese", value: "pt" },
|
|
||||||
{ label: "Japanese", value: "ja" },
|
|
||||||
{ label: "German", value: "de" },
|
|
||||||
{ label: "Italian", value: "it" },
|
|
||||||
{ label: "Korean", value: "ko" },
|
|
||||||
{ label: "Turkish", value: "tr" },
|
|
||||||
{ label: "Dutch", value: "nl" },
|
|
||||||
{ label: "Polish", value: "pl" },
|
|
||||||
{ label: "Vietnamese", value: "vi" },
|
|
||||||
{ label: "Thai", value: "th" },
|
|
||||||
{ label: "Indonesian", value: "id" },
|
|
||||||
{ label: "Greek", value: "el" },
|
|
||||||
{ label: "Swedish", value: "sv" },
|
|
||||||
{ label: "Danish", value: "da" },
|
|
||||||
{ label: "Norwegian", value: "no" },
|
|
||||||
{ label: "Finnish", value: "fi" },
|
|
||||||
{ label: "Czech", value: "cs" },
|
|
||||||
{ label: "Hungarian", value: "hu" },
|
|
||||||
{ label: "Romanian", value: "ro" },
|
|
||||||
{ label: "Ukrainian", value: "uk" },
|
|
||||||
{ label: "Hebrew", value: "he" },
|
|
||||||
{ label: "Bengali", value: "bn" },
|
|
||||||
{ label: "Punjabi", value: "pa" },
|
|
||||||
{ label: "Tagalog", value: "tl" },
|
|
||||||
{ label: "Swahili", value: "sw" },
|
|
||||||
{ label: "Malay", value: "ms" },
|
|
||||||
{ label: "Persian", value: "fa" },
|
|
||||||
{ label: "Urdu", value: "ur" },
|
|
||||||
];
|
|
||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.15.0",
|
"channel": "0.12.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.15.0",
|
"channel": "0.12.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
interface AdjacentEpisodesProps {
|
|
||||||
currentlyPlaying?: CurrentlyPlayingState | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAdjacentEpisodes = ({
|
|
||||||
currentlyPlaying,
|
|
||||||
}: AdjacentEpisodesProps) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const { data: previousItem } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"previousItem",
|
|
||||||
currentlyPlaying?.item.ParentId,
|
|
||||||
currentlyPlaying?.item.IndexNumber,
|
|
||||||
],
|
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!currentlyPlaying?.item.ParentId ||
|
|
||||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
|
||||||
currentlyPlaying?.item.IndexNumber === null ||
|
|
||||||
currentlyPlaying.item.IndexNumber - 2 < 0
|
|
||||||
) {
|
|
||||||
console.log("No previous item");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getItemsApi(api).getItems({
|
|
||||||
parentId: currentlyPlaying.item.ParentId!,
|
|
||||||
startIndex: currentlyPlaying.item.IndexNumber! - 2,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
|
||||||
},
|
|
||||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: nextItem } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"nextItem",
|
|
||||||
currentlyPlaying?.item.ParentId,
|
|
||||||
currentlyPlaying?.item.IndexNumber,
|
|
||||||
],
|
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!currentlyPlaying?.item.ParentId ||
|
|
||||||
currentlyPlaying?.item.IndexNumber === undefined ||
|
|
||||||
currentlyPlaying?.item.IndexNumber === null
|
|
||||||
) {
|
|
||||||
console.log("No next item");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getItemsApi(api).getItems({
|
|
||||||
parentId: currentlyPlaying.item.ParentId!,
|
|
||||||
startIndex: currentlyPlaying.item.IndexNumber!,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
|
||||||
},
|
|
||||||
enabled: currentlyPlaying?.item.Type === "Episode",
|
|
||||||
});
|
|
||||||
|
|
||||||
return { previousItem, nextItem };
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedReaction,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
|
|
||||||
export const useControlsVisibility = (timeout: number = 3000) => {
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
|
|
||||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const showControls = useCallback(() => {
|
|
||||||
opacity.value = 1;
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
hideControlsTimerRef.current = setTimeout(() => {
|
|
||||||
opacity.value = 0;
|
|
||||||
}, timeout);
|
|
||||||
}, [timeout]);
|
|
||||||
|
|
||||||
const hideControls = useCallback(() => {
|
|
||||||
opacity.value = 0;
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { opacity, showControls, hideControls };
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
interface CreditTimestamps {
|
|
||||||
Introduction: {
|
|
||||||
Start: number;
|
|
||||||
End: number;
|
|
||||||
Valid: boolean;
|
|
||||||
};
|
|
||||||
Credits: {
|
|
||||||
Start: number;
|
|
||||||
End: number;
|
|
||||||
Valid: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCreditSkipper = (
|
|
||||||
itemId: string | undefined,
|
|
||||||
currentTime: number,
|
|
||||||
videoRef: React.RefObject<any>
|
|
||||||
) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
|
||||||
|
|
||||||
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
|
||||||
queryKey: ["creditTimestamps", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!itemId) {
|
|
||||||
console.log("No item id");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api?.axiosInstance.get(
|
|
||||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
|
||||||
{
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res?.status !== 200) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res?.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (creditTimestamps) {
|
|
||||||
setShowSkipCreditButton(
|
|
||||||
currentTime > creditTimestamps.Credits.Start &&
|
|
||||||
currentTime < creditTimestamps.Credits.End
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [creditTimestamps, currentTime]);
|
|
||||||
|
|
||||||
const skipCredit = useCallback(() => {
|
|
||||||
if (!creditTimestamps || !videoRef.current) return;
|
|
||||||
try {
|
|
||||||
videoRef.current.seek(creditTimestamps.Credits.End);
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
|
||||||
}
|
|
||||||
}, [creditTimestamps, videoRef]);
|
|
||||||
|
|
||||||
return { showSkipCreditButton, skipCredit };
|
|
||||||
};
|
|
||||||
@@ -28,7 +28,7 @@ export const useImageColors = (
|
|||||||
secondary = colors.muted;
|
secondary = colors.muted;
|
||||||
} else if (colors.platform === "ios") {
|
} else if (colors.platform === "ios") {
|
||||||
primary = colors.primary;
|
primary = colors.primary;
|
||||||
secondary = colors.secondary;
|
secondary = colors.detail;
|
||||||
average = colors.background;
|
average = colors.background;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
interface IntroTimestamps {
|
|
||||||
EpisodeId: string;
|
|
||||||
HideSkipPromptAt: number;
|
|
||||||
IntroEnd: number;
|
|
||||||
IntroStart: number;
|
|
||||||
ShowSkipPromptAt: number;
|
|
||||||
Valid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useIntroSkipper = (
|
|
||||||
itemId: string | undefined,
|
|
||||||
currentTime: number,
|
|
||||||
videoRef: React.RefObject<any>
|
|
||||||
) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
|
||||||
|
|
||||||
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
|
||||||
queryKey: ["introTimestamps", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!itemId) {
|
|
||||||
console.log("No item id");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api?.axiosInstance.get(
|
|
||||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
|
||||||
{
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res?.status !== 200) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res?.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (introTimestamps) {
|
|
||||||
setShowSkipButton(
|
|
||||||
currentTime > introTimestamps.ShowSkipPromptAt &&
|
|
||||||
currentTime < introTimestamps.HideSkipPromptAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [introTimestamps, currentTime]);
|
|
||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
|
||||||
if (!introTimestamps || !videoRef.current) return;
|
|
||||||
try {
|
|
||||||
videoRef.current.seek(introTimestamps.IntroEnd);
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
|
||||||
}
|
|
||||||
}, [introTimestamps, videoRef]);
|
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// hooks/useNavigationBarVisibility.ts
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import * as NavigationBar from "expo-navigation-bar";
|
|
||||||
|
|
||||||
export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const handleVisibility = async () => {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
if (isPlaying) {
|
|
||||||
await NavigationBar.setVisibilityAsync("hidden");
|
|
||||||
} else {
|
|
||||||
await NavigationBar.setVisibilityAsync("visible");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleVisibility();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
NavigationBar.setVisibilityAsync("visible");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isPlaying]);
|
|
||||||
};
|
|
||||||
@@ -7,7 +7,6 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -29,10 +28,6 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
toast.success("Download started", {
|
|
||||||
invert: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
|
||||||
writeToLog(
|
writeToLog(
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
// hooks/useTrickplay.ts
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useRef } from "react";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { SharedValue } from "react-native-reanimated";
|
|
||||||
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
interface TrickplayData {
|
|
||||||
Interval?: number;
|
|
||||||
TileWidth?: number;
|
|
||||||
TileHeight?: number;
|
|
||||||
Height?: number;
|
|
||||||
Width?: number;
|
|
||||||
ThumbnailCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrickplayInfo {
|
|
||||||
resolution: string;
|
|
||||||
aspectRatio: number;
|
|
||||||
data: TrickplayData;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrickplayUrl {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTrickplay = (
|
|
||||||
currentlyPlaying?: CurrentlyPlayingState | null
|
|
||||||
) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
|
||||||
const lastCalculationTime = useRef(0);
|
|
||||||
const throttleDelay = 200; // 200ms throttle
|
|
||||||
|
|
||||||
const trickplayInfo = useMemo(() => {
|
|
||||||
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaSourceId = currentlyPlaying.item.Id;
|
|
||||||
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
|
|
||||||
|
|
||||||
if (!trickplayData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first available resolution
|
|
||||||
const firstResolution = Object.keys(trickplayData)[0];
|
|
||||||
return firstResolution
|
|
||||||
? {
|
|
||||||
resolution: firstResolution,
|
|
||||||
aspectRatio:
|
|
||||||
trickplayData[firstResolution].Width! /
|
|
||||||
trickplayData[firstResolution].Height!,
|
|
||||||
data: trickplayData[firstResolution],
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}, [currentlyPlaying]);
|
|
||||||
|
|
||||||
const calculateTrickplayUrl = useCallback(
|
|
||||||
(progress: number) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastCalculationTime.current < throttleDelay) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
lastCalculationTime.current = now;
|
|
||||||
|
|
||||||
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, resolution } = trickplayInfo;
|
|
||||||
const { Interval, TileWidth, TileHeight } = data;
|
|
||||||
|
|
||||||
if (!Interval || !TileWidth || !TileHeight || !resolution) {
|
|
||||||
throw new Error("Invalid trickplay data");
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSecond = Math.max(0, Math.floor(progress / 10000000));
|
|
||||||
|
|
||||||
const cols = TileWidth;
|
|
||||||
const rows = TileHeight;
|
|
||||||
const imagesPerTile = cols * rows;
|
|
||||||
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
|
|
||||||
const tileIndex = Math.floor(imageIndex / imagesPerTile);
|
|
||||||
|
|
||||||
const positionInTile = imageIndex % imagesPerTile;
|
|
||||||
const rowInTile = Math.floor(positionInTile / cols);
|
|
||||||
const colInTile = positionInTile % cols;
|
|
||||||
|
|
||||||
const newTrickPlayUrl = {
|
|
||||||
x: rowInTile,
|
|
||||||
y: colInTile,
|
|
||||||
url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
setTrickPlayUrl(newTrickPlayUrl);
|
|
||||||
return newTrickPlayUrl;
|
|
||||||
},
|
|
||||||
[trickplayInfo, currentlyPlaying, api]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
|
|
||||||
};
|
|
||||||
29
package.json
29
package.json
@@ -17,27 +17,28 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.3",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
"@gorhom/bottom-sheet": "^4",
|
"@gorhom/bottom-sheet": "^4",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.10.0",
|
||||||
|
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.3",
|
"@react-native-menu/menu": "^1.1.2",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
"@shopify/flash-list": "1.6.4",
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.54.1",
|
||||||
"@types/lodash": "^4.17.9",
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"expo": "~51.0.34",
|
"expo": "~51.0.32",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-dev-client": "~4.0.27",
|
"expo-dev-client": "~4.0.26",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.10",
|
"expo-font": "~12.0.10",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
"expo-image": "~1.13.0",
|
"expo-image": "~1.12.15",
|
||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linear-gradient": "~13.0.2",
|
"expo-linear-gradient": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
@@ -46,37 +47,35 @@
|
|||||||
"expo-router": "~3.5.23",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
"expo-screen-orientation": "~7.0.5",
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-sensors": "~13.0.9",
|
||||||
"expo-splash-screen": "~0.27.6",
|
"expo-splash-screen": "~0.27.5",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.7",
|
"expo-system-ui": "~3.0.7",
|
||||||
"expo-updates": "~0.25.25",
|
"expo-updates": "~0.25.24",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.9.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
"react-native-awesome-slider": "^2.5.3",
|
|
||||||
"react-native-circular-progress": "^1.4.0",
|
"react-native-circular-progress": "^1.4.0",
|
||||||
"react-native-compressor": "^1.8.25",
|
"react-native-compressor": "^1.8.25",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
"react-native-google-cast": "^4.8.2",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^2.5.1",
|
"react-native-ios-context-menu": "^2.5.1",
|
||||||
"react-native-ios-utilities": "^4.4.5",
|
"react-native-ios-utilities": "^4.4.5",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-video": "^6.6.2",
|
"react-native-video": "^6.5.0",
|
||||||
"react-native-web": "~0.19.10",
|
"react-native-web": "~0.19.10",
|
||||||
"sonner-native": "^0.14.2",
|
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.3",
|
"use-debounce": "^10.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.15.0" },
|
clientInfo: { name: "Streamyfin", version: "0.12.0" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -97,7 +97,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.15.0"`,
|
}, DeviceId="${deviceId}", Version="0.12.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -18,19 +18,23 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
PlaybackInfoResponse,
|
PlaybackInfoResponse,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi, getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce, isBuffer } from "lodash";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { OnProgressData, type VideoRef } from "react-native-video";
|
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
parseM3U8ForSubtitles,
|
GroupData,
|
||||||
SubtitleTrack,
|
GroupJoinedData,
|
||||||
} from "@/utils/hls/parseM3U8ForSubtitles";
|
PlayQueueData,
|
||||||
|
StateUpdateData,
|
||||||
|
} from "@/types/syncplay";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
export type CurrentlyPlayingState = {
|
type CurrentlyPlayingState = {
|
||||||
url: string;
|
url: string;
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
};
|
};
|
||||||
@@ -39,6 +43,8 @@ interface PlaybackContextType {
|
|||||||
sessionData: PlaybackInfoResponse | null | undefined;
|
sessionData: PlaybackInfoResponse | null | undefined;
|
||||||
currentlyPlaying: CurrentlyPlayingState | null;
|
currentlyPlaying: CurrentlyPlayingState | null;
|
||||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||||
|
onBuffer: (isBuffering: boolean) => void;
|
||||||
|
onReady: () => void;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
progressTicks: number | null;
|
progressTicks: number | null;
|
||||||
@@ -49,8 +55,6 @@ interface PlaybackContextType {
|
|||||||
dismissFullscreenPlayer: () => void;
|
dismissFullscreenPlayer: () => void;
|
||||||
setIsFullscreen: (isFullscreen: boolean) => void;
|
setIsFullscreen: (isFullscreen: boolean) => void;
|
||||||
setIsPlaying: (isPlaying: boolean) => void;
|
setIsPlaying: (isPlaying: boolean) => void;
|
||||||
isBuffering: boolean;
|
|
||||||
setIsBuffering: (val: boolean) => void;
|
|
||||||
onProgress: (data: OnProgressData) => void;
|
onProgress: (data: OnProgressData) => void;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
setCurrentlyPlayingState: (
|
setCurrentlyPlayingState: (
|
||||||
@@ -59,7 +63,6 @@ interface PlaybackContextType {
|
|||||||
startDownloadedFilePlayback: (
|
startDownloadedFilePlayback: (
|
||||||
currentlyPlaying: CurrentlyPlayingState | null
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
) => void;
|
) => void;
|
||||||
subtitles: SubtitleTrack[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||||
@@ -77,12 +80,11 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const previousVolume = useRef<number | null>(null);
|
const previousVolume = useRef<number | null>(null);
|
||||||
|
|
||||||
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
|
||||||
const [isBuffering, setIsBuffering] = useState<boolean>(false);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||||
const [volume, _setVolume] = useState<number | null>(null);
|
const [volume, _setVolume] = useState<number | null>(null);
|
||||||
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
||||||
const [subtitles, setSubtitles] = useState<SubtitleTrack[]>([]);
|
const [syncplayGroup, setSyncplayGroup] = useState<GroupData | null>(null);
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] =
|
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||||
useState<CurrentlyPlayingState | null>(null);
|
useState<CurrentlyPlayingState | null>(null);
|
||||||
|
|
||||||
@@ -114,12 +116,17 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||||
|
setTimeout(() => {
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[]
|
[settings?.openFullScreenVideoPlayerByDefault]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setCurrentlyPlayingState = useCallback(
|
const setCurrentlyPlayingState = useCallback(
|
||||||
async (state: CurrentlyPlayingState | null) => {
|
async (state: CurrentlyPlayingState | null, paused = false) => {
|
||||||
try {
|
try {
|
||||||
if (state?.item.Id && user?.Id) {
|
if (state?.item.Id && user?.Id) {
|
||||||
const vlcLink = "vlc://" + state?.url;
|
const vlcLink = "vlc://" + state?.url;
|
||||||
@@ -141,7 +148,18 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
setSession(res.data);
|
setSession(res.data);
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
setIsPlaying(true);
|
|
||||||
|
if (paused === true) {
|
||||||
|
pauseVideo();
|
||||||
|
} else {
|
||||||
|
playVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||||
|
setTimeout(() => {
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentlyPlaying(null);
|
setCurrentlyPlaying(null);
|
||||||
setIsFullscreen(false);
|
setIsFullscreen(false);
|
||||||
@@ -159,6 +177,11 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
onPress: () => {
|
onPress: () => {
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||||
|
setTimeout(() => {
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,15 +230,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stopPlayback = useCallback(async () => {
|
const stopPlayback = useCallback(async () => {
|
||||||
const id = currentlyPlaying?.item?.Id;
|
|
||||||
setCurrentlyPlayingState(null);
|
|
||||||
|
|
||||||
await reportPlaybackStopped({
|
await reportPlaybackStopped({
|
||||||
api,
|
api,
|
||||||
itemId: id,
|
itemId: currentlyPlaying?.item?.Id,
|
||||||
sessionId: session?.PlaySessionId,
|
sessionId: session?.PlaySessionId,
|
||||||
positionTicks: progressTicks ? progressTicks : 0,
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
});
|
});
|
||||||
|
setCurrentlyPlayingState(null);
|
||||||
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
|
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
|
||||||
|
|
||||||
const setIsPlaying = useCallback(
|
const setIsPlaying = useCallback(
|
||||||
@@ -246,10 +267,57 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
|
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onBuffer = useCallback(
|
||||||
|
(isBuffering: boolean) => {
|
||||||
|
console.log("Buffering...", "Playing:", isPlaying);
|
||||||
|
if (
|
||||||
|
isBuffering &&
|
||||||
|
syncplayGroup?.GroupId &&
|
||||||
|
isPlaying === false &&
|
||||||
|
currentlyPlaying?.item.PlaylistItemId
|
||||||
|
) {
|
||||||
|
console.log("Sending syncplay buffering...");
|
||||||
|
getSyncPlayApi(api!).syncPlayBuffering({
|
||||||
|
bufferRequestDto: {
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
When: new Date().toISOString(),
|
||||||
|
PositionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isPlaying,
|
||||||
|
syncplayGroup?.GroupId,
|
||||||
|
currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
api,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onReady = useCallback(() => {
|
||||||
|
if (syncplayGroup?.GroupId && currentlyPlaying?.item.PlaylistItemId) {
|
||||||
|
getSyncPlayApi(api!).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: new Date().toISOString(),
|
||||||
|
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PositionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
syncplayGroup?.GroupId,
|
||||||
|
currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
progressTicks,
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
]);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
debounce((e: OnProgressData) => {
|
debounce((e: OnProgressData) => {
|
||||||
_onProgress(e);
|
_onProgress(e);
|
||||||
}, 500),
|
}, 1000),
|
||||||
[_onProgress]
|
[_onProgress]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -263,63 +331,185 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setIsFullscreen(false);
|
setIsFullscreen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const seek = useCallback((ticks: number) => {
|
||||||
|
const time = ticks / 10000000;
|
||||||
|
videoRef.current?.seek(time);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deviceId || !api?.accessToken) return;
|
if (!deviceId || !api?.accessToken || !user?.Id) {
|
||||||
|
console.info("[WS] Waiting for deviceId, accessToken and userId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
||||||
|
|
||||||
const url = `${protocol}://${api?.basePath
|
const url = `${protocol}://${api?.basePath
|
||||||
.replace("https://", "")
|
.replace("https://", "")
|
||||||
.replace("http://", "")}/socket?api_key=${
|
.replace("http://", "")}/socket?api_key=${
|
||||||
api?.accessToken
|
api?.accessToken
|
||||||
}&deviceId=${deviceId}`;
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
const newWebSocket = new WebSocket(url);
|
let ws: WebSocket | null = null;
|
||||||
|
|
||||||
let keepAliveInterval: NodeJS.Timeout | null = null;
|
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
newWebSocket.onopen = () => {
|
const connect = () => {
|
||||||
setIsConnected(true);
|
ws = new WebSocket(url);
|
||||||
// Start sending "KeepAlive" message every 30 seconds
|
|
||||||
keepAliveInterval = setInterval(() => {
|
ws.onopen = () => {
|
||||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
setIsConnected(true);
|
||||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
keepAliveInterval = setInterval(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("⬆︎ KeepAlive...");
|
||||||
|
ws.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error("WebSocket error:", e);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
}, 30000);
|
setTimeout(connect, 5000); // Attempt to reconnect after 5 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
setWs(ws);
|
||||||
};
|
};
|
||||||
|
|
||||||
newWebSocket.onerror = (e) => {
|
connect();
|
||||||
console.error("WebSocket error:", e);
|
|
||||||
setIsConnected(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
newWebSocket.onclose = (e) => {
|
|
||||||
if (keepAliveInterval) {
|
|
||||||
clearInterval(keepAliveInterval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setWs(newWebSocket);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
if (keepAliveInterval) {
|
if (keepAliveInterval) {
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
newWebSocket.close();
|
|
||||||
};
|
};
|
||||||
}, [api, deviceId, user]);
|
}, [api?.accessToken, deviceId, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (!ws || !api) return;
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
const command = json?.Data?.Command;
|
const command = json?.Data?.Command;
|
||||||
|
|
||||||
console.log("[WS] ~ ", json);
|
if (json.MessageType === "KeepAlive") {
|
||||||
|
console.log("⬇︎ KeepAlive...");
|
||||||
|
} else if (json.MessageType === "ForceKeepAlive") {
|
||||||
|
console.log("⬇︎ ForceKeepAlive...");
|
||||||
|
} else if (json.MessageType === "SyncPlayCommand") {
|
||||||
|
console.log("SyncPlayCommand ~", command, json.Data);
|
||||||
|
switch (command) {
|
||||||
|
case "Stop":
|
||||||
|
console.log("STOP");
|
||||||
|
stopPlayback();
|
||||||
|
break;
|
||||||
|
case "Pause":
|
||||||
|
console.log("PAUSE");
|
||||||
|
pauseVideo();
|
||||||
|
break;
|
||||||
|
case "Play":
|
||||||
|
case "Unpause":
|
||||||
|
console.log("PLAY");
|
||||||
|
playVideo();
|
||||||
|
break;
|
||||||
|
case "Seek":
|
||||||
|
console.log("SEEK", json.Data.PositionTicks);
|
||||||
|
seek(json.Data.PositionTicks);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (json.MessageType === "SyncPlayGroupUpdate") {
|
||||||
|
const type = json.Data.Type;
|
||||||
|
|
||||||
|
if (type === "StateUpdate") {
|
||||||
|
const data = json.Data.Data as StateUpdateData;
|
||||||
|
console.log("StateUpdate ~", data);
|
||||||
|
} else if (type === "GroupJoined") {
|
||||||
|
const data = json.Data.Data as GroupData;
|
||||||
|
setSyncplayGroup(data);
|
||||||
|
console.log("GroupJoined ~", data);
|
||||||
|
} else if (type === "GroupLeft") {
|
||||||
|
console.log("GroupLeft");
|
||||||
|
setSyncplayGroup(null);
|
||||||
|
} else if (type === "PlayQueue") {
|
||||||
|
const data = json.Data.Data as PlayQueueData;
|
||||||
|
console.log("PlayQueue ~", {
|
||||||
|
IsPlaying: data.IsPlaying,
|
||||||
|
Reason: data.Reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.Reason === "SetCurrentItem") {
|
||||||
|
console.log("SetCurrentItem ~ ", json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Reason === "NewPlaylist") {
|
||||||
|
const itemId = data.Playlist?.[data.PlayingItemIndex].ItemId;
|
||||||
|
if (!itemId) {
|
||||||
|
console.error("No itemId found in PlayQueue");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set playback item
|
||||||
|
getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId,
|
||||||
|
}).then(async (item) => {
|
||||||
|
if (!item) {
|
||||||
|
Alert.alert("Error", "Could not find item for syncplay");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: data.StartPositionTicks,
|
||||||
|
userId: user?.Id,
|
||||||
|
mediaSourceId: item?.MediaSources?.[0].Id!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
Alert.alert("Error", "Could not find stream url for syncplay");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setCurrentlyPlayingState(
|
||||||
|
{
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
!data.IsPlaying
|
||||||
|
);
|
||||||
|
|
||||||
|
await getSyncPlayApi(api).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
IsPlaying: data.IsPlaying,
|
||||||
|
PositionTicks: data.StartPositionTicks,
|
||||||
|
PlaylistItemId: data.Playlist[0].PlaylistItemId,
|
||||||
|
When: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[WS] ~ ", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log("[WS] ~ ", json);
|
||||||
|
}
|
||||||
|
|
||||||
// On PlayPause
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
|
// On PlayPause
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
if (isPlaying) pauseVideo();
|
if (isPlaying) pauseVideo();
|
||||||
else playVideo();
|
else playVideo();
|
||||||
@@ -341,18 +531,18 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
Alert.alert(title, body);
|
Alert.alert(title, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
}, [ws, stopPlayback, playVideo, pauseVideo, setVolume, api, seek]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaybackContext.Provider
|
<PlaybackContext.Provider
|
||||||
value={{
|
value={{
|
||||||
onProgress,
|
onProgress,
|
||||||
isBuffering,
|
onReady,
|
||||||
setIsBuffering,
|
|
||||||
progressTicks,
|
progressTicks,
|
||||||
setVolume,
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setIsFullscreen,
|
setIsFullscreen,
|
||||||
|
onBuffer,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentlyPlaying,
|
currentlyPlaying,
|
||||||
@@ -365,7 +555,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
presentFullscreenPlayer,
|
presentFullscreenPlayer,
|
||||||
dismissFullscreenPlayer,
|
dismissFullscreenPlayer,
|
||||||
startDownloadedFilePlayback,
|
startDownloadedFilePlayback,
|
||||||
subtitles,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
47
types/syncplay.ts
Normal file
47
types/syncplay.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export type PlaylistItem = {
|
||||||
|
ItemId: string;
|
||||||
|
PlaylistItemId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayQueueData = {
|
||||||
|
IsPlaying: boolean;
|
||||||
|
LastUpdate: string;
|
||||||
|
PlayingItemIndex: number;
|
||||||
|
Playlist: PlaylistItem[];
|
||||||
|
Reason: "NewPlaylist" | "SetCurrentItem"; // or use string if more values are expected
|
||||||
|
RepeatMode: "RepeatNone"; // or use string if more values are expected
|
||||||
|
ShuffleMode: "Sorted"; // or use string if more values are expected
|
||||||
|
StartPositionTicks: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupData = {
|
||||||
|
GroupId: string;
|
||||||
|
GroupName: string;
|
||||||
|
LastUpdatedAt: string;
|
||||||
|
Participants: Participant[];
|
||||||
|
State: string; // You can use an enum or union type if there are known possible states
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncPlayCommandData = {
|
||||||
|
Command: string;
|
||||||
|
EmittedAt: string;
|
||||||
|
GroupId: string;
|
||||||
|
PlaylistItemId: string;
|
||||||
|
PositionTicks: number;
|
||||||
|
When: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StateUpdateData = {
|
||||||
|
State: "Waiting" | "Playing" | "Paused";
|
||||||
|
Reason: "Pause" | "Unpause";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupJoinedData = {
|
||||||
|
GroupId: string;
|
||||||
|
GroupName: string;
|
||||||
|
LastUpdatedAt: string;
|
||||||
|
Participants: string[];
|
||||||
|
State: "Idle";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Participant = string[];
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Orientation, OrientationLock } from "expo-screen-orientation";
|
|
||||||
|
|
||||||
function orientationToOrientationLock(
|
|
||||||
orientation: Orientation
|
|
||||||
): OrientationLock {
|
|
||||||
switch (orientation) {
|
|
||||||
case Orientation.PORTRAIT_UP:
|
|
||||||
return OrientationLock.PORTRAIT_UP;
|
|
||||||
case Orientation.PORTRAIT_DOWN:
|
|
||||||
return OrientationLock.PORTRAIT_DOWN;
|
|
||||||
case Orientation.LANDSCAPE_LEFT:
|
|
||||||
return OrientationLock.LANDSCAPE_LEFT;
|
|
||||||
case Orientation.LANDSCAPE_RIGHT:
|
|
||||||
return OrientationLock.LANDSCAPE_RIGHT;
|
|
||||||
case Orientation.UNKNOWN:
|
|
||||||
default:
|
|
||||||
return OrientationLock.DEFAULT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default orientationToOrientationLock;
|
|
||||||
@@ -48,19 +48,6 @@ const calculateRelativeLuminance = (rgb: number[]): number => {
|
|||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCloseToBlack = (color: string): boolean => {
|
|
||||||
const r = parseInt(color.slice(1, 3), 16);
|
|
||||||
const g = parseInt(color.slice(3, 5), 16);
|
|
||||||
const b = parseInt(color.slice(5, 7), 16);
|
|
||||||
|
|
||||||
// Check if the color is very dark (close to black)
|
|
||||||
return r < 20 && g < 20 && b < 20;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustToNearBlack = (color: string): string => {
|
|
||||||
return "#212121"; // A very dark gray, almost black
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseThemeColorAtom = atom<ThemeColors>({
|
const baseThemeColorAtom = atom<ThemeColors>({
|
||||||
primary: "#FFFFFF",
|
primary: "#FFFFFF",
|
||||||
secondary: "#000000",
|
secondary: "#000000",
|
||||||
@@ -72,15 +59,12 @@ export const itemThemeColorAtom = atom(
|
|||||||
(get) => get(baseThemeColorAtom),
|
(get) => get(baseThemeColorAtom),
|
||||||
(get, set, update: Partial<ThemeColors>) => {
|
(get, set, update: Partial<ThemeColors>) => {
|
||||||
const currentColors = get(baseThemeColorAtom);
|
const currentColors = get(baseThemeColorAtom);
|
||||||
let newColors = { ...currentColors, ...update };
|
const newColors = { ...currentColors, ...update };
|
||||||
|
|
||||||
// Adjust primary color if it's too close to black
|
|
||||||
if (newColors.primary && isCloseToBlack(newColors.primary)) {
|
|
||||||
newColors.primary = adjustToNearBlack(newColors.primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recalculate text color if primary color changes
|
// Recalculate text color if primary color changes
|
||||||
if (update.primary) newColors.text = calculateTextColor(newColors.primary);
|
if (update.average) {
|
||||||
|
newColors.text = calculateTextColor(update.average);
|
||||||
|
}
|
||||||
|
|
||||||
set(baseThemeColorAtom, newColors);
|
set(baseThemeColorAtom, newColors);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
|
|
||||||
export type DownloadQuality = "original" | "high" | "low";
|
export type DownloadQuality = "original" | "high" | "low";
|
||||||
|
|
||||||
@@ -10,22 +9,6 @@ export type DownloadOption = {
|
|||||||
value: DownloadQuality;
|
value: DownloadQuality;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScreenOrientationEnum: Record<
|
|
||||||
ScreenOrientation.OrientationLock,
|
|
||||||
string
|
|
||||||
> = {
|
|
||||||
[ScreenOrientation.OrientationLock.DEFAULT]: "Default",
|
|
||||||
[ScreenOrientation.OrientationLock.ALL]: "All",
|
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait",
|
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up",
|
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down",
|
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape",
|
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left",
|
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right",
|
|
||||||
[ScreenOrientation.OrientationLock.OTHER]: "Other",
|
|
||||||
[ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadOptions: DownloadOption[] = [
|
export const DownloadOptions: DownloadOption[] = [
|
||||||
{
|
{
|
||||||
label: "Original quality",
|
label: "Original quality",
|
||||||
@@ -57,6 +40,7 @@ export type DefaultLanguageOption = {
|
|||||||
type Settings = {
|
type Settings = {
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
forceLandscapeInVideoPlayer?: boolean;
|
forceLandscapeInVideoPlayer?: boolean;
|
||||||
|
openFullScreenVideoPlayerByDefault?: boolean;
|
||||||
usePopularPlugin?: boolean;
|
usePopularPlugin?: boolean;
|
||||||
deviceProfile?: "Expo" | "Native" | "Old";
|
deviceProfile?: "Expo" | "Native" | "Old";
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
@@ -69,10 +53,8 @@ type Settings = {
|
|||||||
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||||
defaultAudioLanguage: DefaultLanguageOption | null;
|
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||||
showHomeTitles: boolean;
|
showHomeTitles: boolean;
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
|
||||||
forwardSkipTime: number;
|
|
||||||
rewindSkipTime: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* The settings atom is a Jotai atom that stores the user's settings.
|
* The settings atom is a Jotai atom that stores the user's settings.
|
||||||
@@ -85,6 +67,7 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
const defaultValues: Settings = {
|
const defaultValues: Settings = {
|
||||||
autoRotate: true,
|
autoRotate: true,
|
||||||
forceLandscapeInVideoPlayer: false,
|
forceLandscapeInVideoPlayer: false,
|
||||||
|
openFullScreenVideoPlayerByDefault: false,
|
||||||
usePopularPlugin: false,
|
usePopularPlugin: false,
|
||||||
deviceProfile: "Expo",
|
deviceProfile: "Expo",
|
||||||
forceDirectPlay: false,
|
forceDirectPlay: false,
|
||||||
@@ -103,9 +86,6 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
defaultAudioLanguage: null,
|
defaultAudioLanguage: null,
|
||||||
defaultSubtitleLanguage: null,
|
defaultSubtitleLanguage: null,
|
||||||
showHomeTitles: true,
|
showHomeTitles: true,
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
|
||||||
forwardSkipTime: 30,
|
|
||||||
rewindSkipTime: 10,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert bits to megabits or gigabits
|
|
||||||
*
|
|
||||||
* Return nice looking string
|
|
||||||
* If under 1000Mb, return XXXMB, else return X.XGB
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
|
||||||
if (!bits) return "0MB";
|
|
||||||
|
|
||||||
const megabits = bits / 1000000;
|
|
||||||
|
|
||||||
if (megabits < 1000) {
|
|
||||||
return Math.round(megabits) + "MB";
|
|
||||||
} else {
|
|
||||||
const gigabits = megabits / 1000;
|
|
||||||
return gigabits.toFixed(1) + "GB";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
|
||||||
index: number;
|
|
||||||
name: string;
|
|
||||||
uri: string;
|
|
||||||
language: string;
|
|
||||||
default: boolean;
|
|
||||||
forced: boolean;
|
|
||||||
autoSelect: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseM3U8ForSubtitles(
|
|
||||||
url: string
|
|
||||||
): Promise<SubtitleTrack[]> {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(url, { responseType: "text" });
|
|
||||||
const lines = response.data.split(/\r?\n/);
|
|
||||||
const subtitleTracks: SubtitleTrack[] = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
lines.forEach((line: string) => {
|
|
||||||
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
|
||||||
const attributes = parseAttributes(line);
|
|
||||||
const track: SubtitleTrack = {
|
|
||||||
index: index++,
|
|
||||||
name: attributes["NAME"] || "",
|
|
||||||
uri: attributes["URI"] || "",
|
|
||||||
language: attributes["LANGUAGE"] || "",
|
|
||||||
default: attributes["DEFAULT"] === "YES",
|
|
||||||
forced: attributes["FORCED"] === "YES",
|
|
||||||
autoSelect: attributes["AUTOSELECT"] === "YES",
|
|
||||||
};
|
|
||||||
subtitleTracks.push(track);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return subtitleTracks;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch or parse the M3U8 file:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttributes(line: string): { [key: string]: string } {
|
|
||||||
const attributes: { [key: string]: string } = {};
|
|
||||||
const parts = line.split(",");
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const [key, value] = part.split("=");
|
|
||||||
if (key && value) {
|
|
||||||
attributes[key.trim()] = value.replace(/"/g, "").trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
PlaybackInfoResponse,
|
PlaybackInfoResponse,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
|
||||||
|
|
||||||
export const getStreamUrl = async ({
|
export const getStreamUrl = async ({
|
||||||
api,
|
api,
|
||||||
@@ -16,7 +15,7 @@ export const getStreamUrl = async ({
|
|||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile = ios,
|
deviceProfile = ios,
|
||||||
audioStreamIndex = 0,
|
audioStreamIndex = 0,
|
||||||
subtitleStreamIndex = undefined,
|
subtitleStreamIndex = 0,
|
||||||
forceDirectPlay = false,
|
forceDirectPlay = false,
|
||||||
height,
|
height,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
@@ -26,8 +25,8 @@ export const getStreamUrl = async ({
|
|||||||
userId: string | null | undefined;
|
userId: string | null | undefined;
|
||||||
startTimeTicks: number;
|
startTimeTicks: number;
|
||||||
maxStreamingBitrate?: number;
|
maxStreamingBitrate?: number;
|
||||||
sessionData: PlaybackInfoResponse;
|
sessionData?: PlaybackInfoResponse;
|
||||||
deviceProfile: any;
|
deviceProfile?: any;
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
@@ -40,9 +39,6 @@ export const getStreamUrl = async ({
|
|||||||
|
|
||||||
const itemId = item.Id;
|
const itemId = item.Id;
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the stream URL for videos
|
|
||||||
*/
|
|
||||||
const response = await api.axiosInstance.post(
|
const response = await api.axiosInstance.post(
|
||||||
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
||||||
{
|
{
|
||||||
@@ -62,7 +58,9 @@ export const getStreamUrl = async ({
|
|||||||
EnableMpegtsM2TsMode: false,
|
EnableMpegtsM2TsMode: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: getAuthHeaders(api),
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,16 +72,14 @@ export const getStreamUrl = async ({
|
|||||||
throw new Error("No media source");
|
throw new Error("No media source");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionData.PlaySessionId) {
|
|
||||||
throw new Error("no PlaySessionId");
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: string | null | undefined;
|
let url: string | null | undefined;
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
console.log("Using direct stream for video!");
|
||||||
|
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
|
console.log("Using direct stream for audio!");
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
DeviceId: api.deviceInfo.id,
|
DeviceId: api.deviceInfo.id,
|
||||||
@@ -94,7 +90,9 @@ export const getStreamUrl = async ({
|
|||||||
TranscodingProtocol: "hls",
|
TranscodingProtocol: "hls",
|
||||||
AudioCodec: "aac",
|
AudioCodec: "aac",
|
||||||
api_key: api.accessToken,
|
api_key: api.accessToken,
|
||||||
PlaySessionId: sessionData.PlaySessionId,
|
PlaySessionId: sessionData?.PlaySessionId
|
||||||
|
? sessionData.PlaySessionId
|
||||||
|
: "",
|
||||||
StartTimeTicks: "0",
|
StartTimeTicks: "0",
|
||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
@@ -104,6 +102,7 @@ export const getStreamUrl = async ({
|
|||||||
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
} else if (mediaSource.TranscodingUrl) {
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
|
console.log("Using transcoded stream!");
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
interface PlaybackStoppedParams {
|
interface PlaybackStoppedParams {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
@@ -27,17 +28,23 @@ export const reportPlaybackStopped = async ({
|
|||||||
if (!positionTicks || positionTicks === 0) return;
|
if (!positionTicks || positionTicks === 0) return;
|
||||||
|
|
||||||
if (!api) {
|
if (!api) {
|
||||||
console.error("Missing api");
|
writeToLog("WARN", "Could not report playback stopped due to missing api");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
console.error("Missing sessionId", sessionId);
|
writeToLog(
|
||||||
|
"WARN",
|
||||||
|
"Could not report playback stopped due to missing session id"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
console.error("Missing itemId");
|
writeToLog(
|
||||||
|
"WARN",
|
||||||
|
"Could not report playback progress due to missing item id"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// seconds to ticks util
|
|
||||||
|
|
||||||
export function secondsToTicks(seconds: number): number {
|
|
||||||
return seconds * 10000000;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
|
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
|
||||||
*/
|
*/
|
||||||
export const runtimeTicksToMinutes = (
|
export const runtimeTicksToMinutes = (
|
||||||
ticks: number | null | undefined
|
ticks: number | null | undefined,
|
||||||
): string => {
|
): string => {
|
||||||
if (!ticks) return "0h 0m";
|
if (!ticks) return "0h 0m";
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export const runtimeTicksToMinutes = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const runtimeTicksToSeconds = (
|
export const runtimeTicksToSeconds = (
|
||||||
ticks: number | null | undefined
|
ticks: number | null | undefined,
|
||||||
): string => {
|
): string => {
|
||||||
if (!ticks) return "0h 0m";
|
if (!ticks) return "0h 0m";
|
||||||
|
|
||||||
@@ -34,37 +34,3 @@ export const runtimeTicksToSeconds = (
|
|||||||
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
else return `${minutes}m ${seconds}s`;
|
else return `${minutes}m ${seconds}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatTimeString = (
|
|
||||||
t: number | null | undefined,
|
|
||||||
tick = false
|
|
||||||
): string => {
|
|
||||||
if (t === null || t === undefined) return "0:00";
|
|
||||||
|
|
||||||
let seconds = t;
|
|
||||||
if (tick) {
|
|
||||||
seconds = Math.floor(t / 10000000); // Convert ticks to seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seconds < 0) return "0:00";
|
|
||||||
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m ${remainingSeconds}s`;
|
|
||||||
} else {
|
|
||||||
return `${minutes}m ${remainingSeconds}s`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const secondsToTicks = (seconds?: number | undefined) => {
|
|
||||||
if (!seconds) return 0;
|
|
||||||
return seconds * 10000000;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ticksToSeconds = (ticks?: number | undefined) => {
|
|
||||||
if (!ticks) return 0;
|
|
||||||
return ticks / 10000000;
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user