mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
32 Commits
wip/genera
...
feat/syncp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4eaabce7a | ||
|
|
788b4bcbd2 | ||
|
|
acbc650ccf | ||
|
|
c25b26653e | ||
|
|
5d3a1d9058 | ||
|
|
dbaba93fbf | ||
|
|
4a1ea7ea70 | ||
|
|
c33890a0fe | ||
|
|
35a470c4ae | ||
|
|
a69be4eab9 | ||
|
|
fced376a68 | ||
|
|
848a5aac1a | ||
|
|
5608646c8b | ||
|
|
cdc3be41c1 | ||
|
|
3f4826c4ce | ||
|
|
e173d51dbb | ||
|
|
b4fdbcf63d | ||
|
|
f33c4ca690 | ||
|
|
1318eafa43 | ||
|
|
d222c54bae | ||
|
|
f24b5612b2 | ||
|
|
6713098dc7 | ||
|
|
0357554f6a | ||
|
|
2fc9229db0 | ||
|
|
4781df0ba3 | ||
|
|
db94cfaa79 | ||
|
|
7d5397b545 | ||
|
|
fac50ed569 | ||
|
|
4994df390c | ||
|
|
67214a81c4 | ||
|
|
2509a8d6e2 | ||
|
|
c31eb498ea |
29
README.md
29
README.md
@@ -4,12 +4,11 @@
|
||||
|
||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 5px">
|
||||
<img width=100 src="./assets/images/screenshots/1.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/3.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/4.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/5.jpg" />
|
||||
<img width=100 src="./assets/images/screenshots/7.jpg" />
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||
|
||||
</div>
|
||||
|
||||
## 🌟 Features
|
||||
@@ -26,7 +25,7 @@ Streamyfin includes some exciting experimental features like media downloading a
|
||||
|
||||
### Downloading
|
||||
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### Chromecast
|
||||
|
||||
@@ -34,19 +33,19 @@ Chromecast support is still in development, and we're working on improving it. C
|
||||
|
||||
## Plugins
|
||||
|
||||
In Streamyfin we have build in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
||||
In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
||||
|
||||
### Collection rows
|
||||
|
||||
Jellyfin collections can be shown as rows or carousel on the home screen.
|
||||
The following tags can be added to an collection to provide this functionality.
|
||||
The following tags can be added to a collection to provide this functionality.
|
||||
|
||||
Avaiable tags:
|
||||
Available tags:
|
||||
|
||||
- sf_promoted: Wil make the collection an row on home
|
||||
- sf_carousel: Wil make the collection an carousel on home.
|
||||
- sf_promoted: will make the collection a row at home
|
||||
- sf_carousel: will make the collection a carousel on home.
|
||||
|
||||
A plugin exists to create collections based on external sources like mdblist. This makes managing collections like trending, most watched etc an automatic process.
|
||||
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
|
||||
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
|
||||
|
||||
### Jellysearch
|
||||
@@ -90,8 +89,8 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
||||
### Development info
|
||||
|
||||
1. Use node `20`
|
||||
2. Install deps `bun i`
|
||||
3. `Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||
2. Install dependencies `bun i`
|
||||
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||
|
||||
## Extended chromecast controls
|
||||
|
||||
|
||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.10.3",
|
||||
"version": "0.12.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 32,
|
||||
"versionCode": 36,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
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 { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
@@ -32,6 +32,16 @@ export default function IndexLayout() {
|
||||
),
|
||||
headerRight: () => (
|
||||
<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 />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
@@ -58,6 +68,13 @@ export default function IndexLayout() {
|
||||
title: "Settings",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="syncplay"
|
||||
options={{
|
||||
title: "Syncplay",
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
@@ -5,6 +6,8 @@ import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
@@ -18,7 +21,13 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type BaseSection = {
|
||||
@@ -41,6 +50,7 @@ type Section = ScrollingCollectionListSection | MediaListSection;
|
||||
|
||||
export default function index() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -49,6 +59,14 @@ export default function index() {
|
||||
const [settings, _] = useSettings();
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
setLoadingRetry(true);
|
||||
const state = await NetInfo.fetch();
|
||||
setIsConnected(state.isConnected);
|
||||
setLoadingRetry(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
@@ -187,6 +205,7 @@ export default function index() {
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie"],
|
||||
parentId: movieCollectionId,
|
||||
})
|
||||
).data || [],
|
||||
@@ -203,6 +222,7 @@ export default function index() {
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Series"],
|
||||
parentId: tvShowCollectionId,
|
||||
})
|
||||
).data || [],
|
||||
@@ -226,15 +246,20 @@ export default function index() {
|
||||
{
|
||||
title: "Suggested Episodes",
|
||||
queryKey: ["suggestedEpisodes", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
const nextUpPromises = suggestions.map((series) =>
|
||||
getNextUp(api, user.Id, series.Id)
|
||||
);
|
||||
const nextUpResults = await Promise.all(nextUpPromises);
|
||||
|
||||
return nextUpResults.filter((item) => item !== null) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
@@ -248,28 +273,47 @@ export default function index() {
|
||||
mediaListCollections,
|
||||
]);
|
||||
|
||||
// if (isConnected === false) {
|
||||
// return (
|
||||
// <View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
// <Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
// <Text className="text-center opacity-70">
|
||||
// No worries, you can still watch{"\n"}downloaded content.
|
||||
// </Text>
|
||||
// <View className="mt-4">
|
||||
// <Button
|
||||
// color="purple"
|
||||
// onPress={() => router.push("/(auth)/downloads")}
|
||||
// justify="center"
|
||||
// iconRight={
|
||||
// <Ionicons name="arrow-forward" size={20} color="white" />
|
||||
// }
|
||||
// >
|
||||
// Go to downloads
|
||||
// </Button>
|
||||
// </View>
|
||||
// </View>
|
||||
// );
|
||||
// }
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
No worries, you can still watch{"\n"}downloaded content.
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
Go to downloads
|
||||
</Button>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
checkConnection();
|
||||
}}
|
||||
justify="center"
|
||||
className="mt-2"
|
||||
iconRight={
|
||||
loadingRetry ? null : (
|
||||
<Ionicons name="refresh" size={20} color="white" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{loadingRetry ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
"Retry"
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -333,3 +377,30 @@ export default function index() {
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// Function to get suggestions
|
||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||
if (!userId) return [];
|
||||
const response = await getSuggestionsApi(api).getSuggestions({
|
||||
userId,
|
||||
limit: 10,
|
||||
mediaType: ["Unknown"],
|
||||
type: ["Series"],
|
||||
});
|
||||
return response.data.Items ?? [];
|
||||
}
|
||||
|
||||
// Function to get the next up TV show for a series
|
||||
async function getNextUp(
|
||||
api: Api,
|
||||
userId: string | undefined,
|
||||
seriesId: string | undefined
|
||||
) {
|
||||
if (!userId || !seriesId) return null;
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId,
|
||||
seriesId,
|
||||
limit: 1,
|
||||
});
|
||||
return response.data.Items?.[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -35,11 +35,13 @@ export default function settings() {
|
||||
}}
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
<Text className="font-bold text-2xl">Information</Text>
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||
|
||||
<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="Server" subTitle={api?.basePath} />
|
||||
<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="Server" subTitle={api?.basePath} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingToggles />
|
||||
@@ -71,26 +73,27 @@ export default function settings() {
|
||||
Delete all logs
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="font-bold text-2xl">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<Text
|
||||
className={`
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<Text
|
||||
className={`
|
||||
mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text className="text-xs">{log.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">No logs available</Text>
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text className="text-xs">{log.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">No logs available</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
@@ -33,13 +31,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -17,19 +11,24 @@ import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortByPreferenceAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
@@ -41,8 +40,6 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
@@ -57,10 +54,57 @@ const Page = () => {
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [orientation] = useAtom(orientationAtom);
|
||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom
|
||||
);
|
||||
|
||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||
useEffect(() => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sop) {
|
||||
console.log("getSortOrderPreference ~", sop, libraryId);
|
||||
_setSortOrder([sop]);
|
||||
} else {
|
||||
_setSortOrder([SortOrderOption.Ascending]);
|
||||
}
|
||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||
console.log("getSortByPreference ~", obp, libraryId);
|
||||
if (obp) {
|
||||
_setSortBy([obp]);
|
||||
} else {
|
||||
_setSortBy([SortByOption.SortName]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
(sortBy: SortByOption[]) => {
|
||||
const sop = getSortByPreference(libraryId, sortByPreference);
|
||||
if (sortBy[0] !== sop) {
|
||||
console.log("setSortByPreference ~", sortBy[0], libraryId);
|
||||
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
|
||||
}
|
||||
_setSortBy(sortBy);
|
||||
},
|
||||
[libraryId, sortByPreference]
|
||||
);
|
||||
|
||||
const setSortOrder = useCallback(
|
||||
(sortOrder: SortOrderOption[]) => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sortOrder[0] !== sop) {
|
||||
console.log("setSortOrderPreference ~", sortOrder[0], libraryId);
|
||||
setOderByPreference({
|
||||
...sortOrderPreference,
|
||||
[libraryId]: sortOrder[0],
|
||||
});
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference]
|
||||
);
|
||||
|
||||
const getNumberOfColumns = useCallback(() => {
|
||||
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
||||
@@ -70,11 +114,6 @@ const Page = () => {
|
||||
return 6;
|
||||
}, [screenWidth, orientation]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([SortByOption.SortName]);
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
}, []);
|
||||
|
||||
const { data: library, isLoading: isLibraryLoading } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
|
||||
BIN
assets/images/screenshots/androidscreen.png
Normal file
BIN
assets/images/screenshots/androidscreen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/images/screenshots/screenshot1.png
Normal file
BIN
assets/images/screenshots/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
BIN
assets/images/screenshots/screenshot2.png
Normal file
BIN
assets/images/screenshots/screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 380 KiB |
BIN
assets/images/screenshots/screenshot3.png
Normal file
BIN
assets/images/screenshots/screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
BIN
assets/images/screenshots/screenshot4.png
Normal file
BIN
assets/images/screenshots/screenshot4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -9,6 +9,7 @@ import {
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
@@ -22,6 +23,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
@@ -33,9 +36,22 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultAudioIndex = audioStreams?.find(
|
||||
(x) => x.Language === settings?.defaultAudioLanguage
|
||||
)?.Index;
|
||||
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
|
||||
onChange(defaultAudioIndex);
|
||||
return;
|
||||
}
|
||||
const index = source.DefaultAudioStreamIndex;
|
||||
if (index !== undefined && index !== null) onChange(index);
|
||||
}, []);
|
||||
if (index !== undefined && index !== null) {
|
||||
console.log("DefaultAudioStreamIndex", index);
|
||||
onChange(index);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(0);
|
||||
}, [audioStreams, settings]);
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -31,6 +31,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
videoRef,
|
||||
presentFullscreenPlayer,
|
||||
onProgress,
|
||||
onBuffer,
|
||||
} = usePlayback();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -182,18 +183,24 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
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);
|
||||
}}
|
||||
onFullscreenPlayerDidDismiss={() => {}}
|
||||
onFullscreenPlayerDidPresent={() => {}}
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying === true) {
|
||||
playVideo(false);
|
||||
|
||||
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 { View, ViewProps } from "react-native";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
interface Props extends TouchableOpacityProps {
|
||||
title?: string | null | undefined;
|
||||
subTitle?: string | null | undefined;
|
||||
children?: ReactNode;
|
||||
@@ -17,7 +22,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
<TouchableOpacity
|
||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
||||
{...props}
|
||||
>
|
||||
@@ -26,6 +31,6 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
||||
</View>
|
||||
{iconAfter}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
@@ -23,10 +26,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -55,6 +55,10 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
|
||||
const directStream = useMemo(() => {
|
||||
return !url?.includes("m3u8");
|
||||
}, []);
|
||||
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
if (!client) {
|
||||
@@ -254,51 +258,64 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
*/
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className="relative"
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className="relative"
|
||||
{...props}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
</Animated.Text>
|
||||
)}
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View className="mt-2 flex flex-row items-center">
|
||||
<Ionicons
|
||||
name="information-circle"
|
||||
size={12}
|
||||
className=""
|
||||
color={"#9BA1A6"}
|
||||
/>
|
||||
<Text className="text-neutral-500 ml-1">
|
||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
@@ -22,6 +23,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||
[source]
|
||||
@@ -33,13 +36,21 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = source.DefaultSubtitleStreamIndex;
|
||||
if (index !== undefined && index !== null) {
|
||||
onChange(index);
|
||||
} else {
|
||||
onChange(-1);
|
||||
// const index = source.DefaultAudioStreamIndex;
|
||||
// if (index !== undefined && index !== null) {
|
||||
// onChange(index);
|
||||
// return;
|
||||
// }
|
||||
const defaultSubIndex = subtitleStreams?.find(
|
||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
||||
)?.Index;
|
||||
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
|
||||
onChange(defaultSubIndex);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
onChange(-1);
|
||||
}, [subtitleStreams, settings]);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Alert, TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
|
||||
@@ -23,14 +23,14 @@ interface EpisodeCardProps {
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
const { startDownloadedFilePlayback } = usePlayback();
|
||||
|
||||
const handleOpenFile = useCallback(async () => {
|
||||
setCurrentlyPlayingState({
|
||||
startDownloadedFilePlayback({
|
||||
item,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
}, [item, startDownloadedFilePlayback]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
|
||||
@@ -26,14 +26,14 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
const { startDownloadedFilePlayback } = usePlayback();
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
setCurrentlyPlayingState({
|
||||
startDownloadedFilePlayback({
|
||||
item,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
}, [item, startDownloadedFilePlayback]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
useQuery,
|
||||
type QueryFunction,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import {
|
||||
type QueryKey,
|
||||
useQuery,
|
||||
type QueryFunction,
|
||||
} from "@tanstack/react-query";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
import { EpisodePoster } from "../posters/EpisodePoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null;
|
||||
@@ -32,6 +32,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
CollectionType,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { sortBy } from "lodash";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacityProps, View } from "react-native";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
library: BaseItemDto;
|
||||
|
||||
150
components/settings/MediaToggles.tsx
Normal file
150
components/settings/MediaToggles.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
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 { Text } from "../common/Text";
|
||||
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 {}
|
||||
|
||||
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||
<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 space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Audio language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default audio language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-audio"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{LANGUAGES.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l.value}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</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">Subtitle language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings?.defaultSubtitleLanguage?.label || "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-subs"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{LANGUAGES.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l.value}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
|
||||
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";
|
||||
@@ -10,6 +14,7 @@ import { Loader } from "../Loader";
|
||||
import { Input } from "../common/Input";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../Button";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
|
||||
export const SettingToggles: React.FC = () => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
@@ -44,314 +49,302 @@ export const SettingToggles: React.FC = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Auto rotate</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Important on android since the video player orientation is locked to
|
||||
the app orientation.
|
||||
</Text>
|
||||
<View>
|
||||
{/* <View>
|
||||
<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-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Coming soon</Text>
|
||||
<Text className="text-xs opacity-50 max-w-[90%]">
|
||||
Options for changing the look and feel of the app.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch disabled />
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.autoRotate}
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</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">Download quality</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the download quality.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.downloadQuality?.label}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Quality</DropdownMenu.Label>
|
||||
{DownloadOptions.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadQuality: option });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View> */}
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Clicking a video will start it in fullscreen mode, instead of
|
||||
inline.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openFullScreenVideoPlayerByDefault}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ openFullScreenVideoPlayerByDefault: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
Open all videos in VLC instead of the default player. This requries
|
||||
VLC to be installed on the phone.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<MediaToggles />
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||
);
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||
|
||||
<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="shrink">
|
||||
<Text className="font-semibold">Auto rotate</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Important on android since the video player orientation is
|
||||
locked to the app orientation.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.autoRotate}
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Clicking a video will start it in fullscreen mode, instead of
|
||||
inline.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openFullScreenVideoPlayerByDefault}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ openFullScreenVideoPlayerByDefault: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
Open all videos in VLC instead of the default player. This
|
||||
requries VLC to be installed on the phone.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
>
|
||||
<Text className="text-xs text-purple-600">More info</Text>
|
||||
</TouchableOpacity>
|
||||
/>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.usePopularPlugin}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ usePopularPlugin: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{settings?.usePopularPlugin && (
|
||||
<View className="flex flex-col py-2 bg-neutral-900">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<View
|
||||
key={mlc.Id}
|
||||
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">{mlc.Name}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
mediaListCollectionIds: [mlc.Id!],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings?.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
||||
});
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Text className="text-xs text-purple-600">More info</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
{isLoadingMediaListCollections && (
|
||||
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
{mediaListCollections?.length === 0 && (
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<Text className="text-xs opacity-50">
|
||||
No collections found. Add some in Jellyfin.
|
||||
</Text>
|
||||
<Switch
|
||||
value={settings?.usePopularPlugin}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ usePopularPlugin: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{settings?.usePopularPlugin && (
|
||||
<View className="flex flex-col py-2 bg-neutral-900">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<View
|
||||
key={mlc.Id}
|
||||
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">{mlc.Name}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.mediaListCollectionIds?.includes(
|
||||
mlc.Id!
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
mediaListCollectionIds: [mlc.Id!],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings?.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{isLoadingMediaListCollections && (
|
||||
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
{mediaListCollections?.length === 0 && (
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<Text className="text-xs opacity-50">
|
||||
No collections found. Add some in Jellyfin.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Force direct play</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
This will always request direct play. This is good if you want to
|
||||
try to stream movies you think the device supports.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.forceDirectPlay}
|
||||
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Force direct play</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
This will always request direct play. This is good if you want
|
||||
to try to stream movies you think the device supports.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.forceDirectPlay}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ forceDirectPlay: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Device profile</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
A profile used for deciding what audio and video codecs the device
|
||||
supports.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Expo" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Native" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Old" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<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">Search engine</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the search engine you want to use.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Device profile</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
A profile used for deciding what audio and video codecs the
|
||||
device supports.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Marlin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
{settings?.searchEngine === "Marlin" && (
|
||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||
<>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className="grow">
|
||||
<Input
|
||||
placeholder="Marlin Server URL..."
|
||||
defaultValue={settings.marlinServerUrl}
|
||||
value={marlinUrl}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={(text) => setMarlinUrl(text)}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
color="purple"
|
||||
className="shrink w-16 h-12"
|
||||
onPress={() => {
|
||||
updateSettings({ marlinServerUrl: marlinUrl });
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Expo" });
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="text-neutral-500 mt-2">
|
||||
{settings?.marlinServerUrl}
|
||||
</Text>
|
||||
</>
|
||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Native" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Old" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
)}
|
||||
<View className="flex flex-col">
|
||||
<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">Search engine</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the search engine you want to use.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Marlin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
{settings?.searchEngine === "Marlin" && (
|
||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||
<>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className="grow">
|
||||
<Input
|
||||
placeholder="Marlin Server URL..."
|
||||
defaultValue={settings.marlinServerUrl}
|
||||
value={marlinUrl}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={(text) => setMarlinUrl(text)}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
color="purple"
|
||||
className="shrink w-16 h-12"
|
||||
onPress={() => {
|
||||
updateSettings({ marlinServerUrl: marlinUrl });
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="text-neutral-500 mt-2">
|
||||
{settings?.marlinServerUrl}
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.10.3",
|
||||
"channel": "0.12.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.10.3",
|
||||
"channel": "0.12.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
15
package.json
15
package.json
@@ -26,23 +26,24 @@
|
||||
"@react-native-menu/menu": "^1.1.2",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^5.51.16",
|
||||
"@tanstack/react-query": "^5.54.1",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"expo": "~51.0.31",
|
||||
"axios": "^1.7.7",
|
||||
"expo": "~51.0.32",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.25",
|
||||
"expo-dev-client": "~4.0.26",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-font": "~12.0.10",
|
||||
"expo-haptics": "~13.0.1",
|
||||
"expo-image": "~1.12.15",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-network": "~6.0.1",
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
@@ -52,7 +53,7 @@
|
||||
"expo-updates": "~0.25.24",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.1",
|
||||
"jotai": "^2.9.3",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
@@ -73,7 +74,7 @@
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.4.5",
|
||||
"react-native-video": "^6.5.0",
|
||||
"react-native-web": "~0.19.10",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.3",
|
||||
|
||||
@@ -63,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.10.3" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.12.0" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
})
|
||||
);
|
||||
@@ -97,7 +97,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.10.3"`,
|
||||
}, DeviceId="${deviceId}", Version="0.12.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -18,13 +18,21 @@ import {
|
||||
BaseItemDto,
|
||||
PlaybackInfoResponse,
|
||||
} 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 { useAtom } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import { Alert, Platform } from "react-native";
|
||||
import { debounce, isBuffer } from "lodash";
|
||||
import { Alert } from "react-native";
|
||||
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
import {
|
||||
GroupData,
|
||||
GroupJoinedData,
|
||||
PlayQueueData,
|
||||
StateUpdateData,
|
||||
} from "@/types/syncplay";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
|
||||
type CurrentlyPlayingState = {
|
||||
url: string;
|
||||
@@ -35,6 +43,8 @@ interface PlaybackContextType {
|
||||
sessionData: PlaybackInfoResponse | null | undefined;
|
||||
currentlyPlaying: CurrentlyPlayingState | null;
|
||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||
onBuffer: (isBuffering: boolean) => void;
|
||||
onReady: () => void;
|
||||
isPlaying: boolean;
|
||||
isFullscreen: boolean;
|
||||
progressTicks: number | null;
|
||||
@@ -50,6 +60,9 @@ interface PlaybackContextType {
|
||||
setCurrentlyPlayingState: (
|
||||
currentlyPlaying: CurrentlyPlayingState | null
|
||||
) => void;
|
||||
startDownloadedFilePlayback: (
|
||||
currentlyPlaying: CurrentlyPlayingState | null
|
||||
) => void;
|
||||
}
|
||||
|
||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||
@@ -71,6 +84,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||
const [volume, _setVolume] = useState<number | null>(null);
|
||||
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
||||
const [syncplayGroup, setSyncplayGroup] = useState<GroupData | null>(null);
|
||||
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||
useState<CurrentlyPlayingState | null>(null);
|
||||
|
||||
@@ -92,41 +106,90 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
queryFn: getDeviceId,
|
||||
});
|
||||
|
||||
const setCurrentlyPlayingState = useCallback(
|
||||
const startDownloadedFilePlayback = useCallback(
|
||||
async (state: CurrentlyPlayingState | null) => {
|
||||
if (!api) return;
|
||||
|
||||
if (state && state.item.Id && user?.Id) {
|
||||
const vlcLink = "vlc://" + state?.url;
|
||||
if (vlcLink && settings?.openInVLC) {
|
||||
Linking.openURL("vlc://" + state?.url || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo({
|
||||
itemId: state.item.Id,
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
await postCapabilities({
|
||||
api,
|
||||
itemId: state.item.Id,
|
||||
sessionId: res.data.PlaySessionId,
|
||||
});
|
||||
|
||||
setSession(res.data);
|
||||
setCurrentlyPlaying(state);
|
||||
setIsPlaying(true);
|
||||
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
if (!state) {
|
||||
setCurrentlyPlaying(null);
|
||||
setIsFullscreen(false);
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentlyPlaying(state);
|
||||
setIsPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[settings?.openFullScreenVideoPlayerByDefault]
|
||||
);
|
||||
|
||||
const setCurrentlyPlayingState = useCallback(
|
||||
async (state: CurrentlyPlayingState | null, paused = false) => {
|
||||
try {
|
||||
if (state?.item.Id && user?.Id) {
|
||||
const vlcLink = "vlc://" + state?.url;
|
||||
if (vlcLink && settings?.openInVLC) {
|
||||
Linking.openURL("vlc://" + state?.url || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: state.item.Id,
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
await postCapabilities({
|
||||
api,
|
||||
itemId: state.item.Id,
|
||||
sessionId: res.data.PlaySessionId,
|
||||
});
|
||||
|
||||
setSession(res.data);
|
||||
setCurrentlyPlaying(state);
|
||||
|
||||
if (paused === true) {
|
||||
pauseVideo();
|
||||
} else {
|
||||
playVideo();
|
||||
}
|
||||
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
setCurrentlyPlaying(null);
|
||||
setIsFullscreen(false);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Alert.alert(
|
||||
"Something went wrong",
|
||||
"The item could not be played. Maybe there is no internet connection?",
|
||||
[
|
||||
{
|
||||
style: "destructive",
|
||||
text: "Try force play",
|
||||
onPress: () => {
|
||||
setCurrentlyPlaying(state);
|
||||
setIsPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Ok",
|
||||
style: "default",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
[settings, user, api]
|
||||
@@ -204,6 +267,53 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
[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(
|
||||
debounce((e: OnProgressData) => {
|
||||
_onProgress(e);
|
||||
@@ -221,63 +331,185 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setIsFullscreen(false);
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((ticks: number) => {
|
||||
const time = ticks / 10000000;
|
||||
videoRef.current?.seek(time);
|
||||
}, []);
|
||||
|
||||
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 url = `${protocol}://${api?.basePath
|
||||
.replace("https://", "")
|
||||
.replace("http://", "")}/socket?api_key=${
|
||||
api?.accessToken
|
||||
}&deviceId=${deviceId}`;
|
||||
|
||||
const newWebSocket = new WebSocket(url);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
newWebSocket.onopen = () => {
|
||||
setIsConnected(true);
|
||||
// Start sending "KeepAlive" message every 30 seconds
|
||||
keepAliveInterval = setInterval(() => {
|
||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||
const connect = () => {
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
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) => {
|
||||
console.error("WebSocket error:", e);
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
newWebSocket.onclose = (e) => {
|
||||
if (keepAliveInterval) {
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
};
|
||||
|
||||
setWs(newWebSocket);
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
if (keepAliveInterval) {
|
||||
clearInterval(keepAliveInterval);
|
||||
}
|
||||
newWebSocket.close();
|
||||
};
|
||||
}, [api, deviceId, user]);
|
||||
}, [api?.accessToken, deviceId, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
if (!ws || !api) return;
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const json = JSON.parse(e.data);
|
||||
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") {
|
||||
// On PlayPause
|
||||
console.log("Command ~ PlayPause");
|
||||
if (isPlaying) pauseVideo();
|
||||
else playVideo();
|
||||
@@ -299,16 +531,18 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
Alert.alert(title, body);
|
||||
}
|
||||
};
|
||||
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
||||
}, [ws, stopPlayback, playVideo, pauseVideo, setVolume, api, seek]);
|
||||
|
||||
return (
|
||||
<PlaybackContext.Provider
|
||||
value={{
|
||||
onProgress,
|
||||
onReady,
|
||||
progressTicks,
|
||||
setVolume,
|
||||
setIsPlaying,
|
||||
setIsFullscreen,
|
||||
onBuffer,
|
||||
isFullscreen,
|
||||
isPlaying,
|
||||
currentlyPlaying,
|
||||
@@ -320,6 +554,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
stopPlayback,
|
||||
presentFullscreenPlayer,
|
||||
dismissFullscreenPlayer,
|
||||
startDownloadedFilePlayback,
|
||||
}}
|
||||
>
|
||||
{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,4 +1,6 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
|
||||
export enum SortByOption {
|
||||
Default = "Default",
|
||||
@@ -65,3 +67,67 @@ export const sortByAtom = atom<SortByOption[]>([SortByOption.Default]);
|
||||
export const sortOrderAtom = atom<SortOrderOption[]>([
|
||||
SortOrderOption.Ascending,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sort preferences with persistence
|
||||
*/
|
||||
export interface SortPreference {
|
||||
[libraryId: string]: SortByOption;
|
||||
}
|
||||
|
||||
export interface SortOrderPreference {
|
||||
[libraryId: string]: SortOrderOption;
|
||||
}
|
||||
|
||||
const defaultSortPreference: SortPreference = {};
|
||||
const defaultSortOrderPreference: SortOrderPreference = {};
|
||||
|
||||
export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
"sortByPreference",
|
||||
defaultSortPreference,
|
||||
{
|
||||
getItem: async (key) => {
|
||||
const value = await AsyncStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await AsyncStorage.removeItem(key);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
|
||||
"sortOrderPreference",
|
||||
defaultSortOrderPreference,
|
||||
{
|
||||
getItem: async (key) => {
|
||||
const value = await AsyncStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await AsyncStorage.removeItem(key);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Helper functions to get and set sort preferences
|
||||
export const getSortByPreference = (
|
||||
libraryId: string,
|
||||
preferences: SortPreference
|
||||
) => {
|
||||
return preferences?.[libraryId] || null;
|
||||
};
|
||||
|
||||
export const getSortOrderPreference = (
|
||||
libraryId: string,
|
||||
preferences: SortOrderPreference
|
||||
) => {
|
||||
return preferences?.[libraryId] || null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { Orientation } from "expo-screen-orientation";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const orientationAtom = atom<number>(
|
||||
|
||||
@@ -32,6 +32,11 @@ export type LibraryOptions = {
|
||||
showStats: boolean;
|
||||
};
|
||||
|
||||
export type DefaultLanguageOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Settings = {
|
||||
autoRotate?: boolean;
|
||||
forceLandscapeInVideoPlayer?: boolean;
|
||||
@@ -45,6 +50,9 @@ type Settings = {
|
||||
openInVLC?: boolean;
|
||||
downloadQuality?: DownloadOption;
|
||||
libraryOptions: LibraryOptions;
|
||||
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||
showHomeTitles: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,6 +83,9 @@ const loadSettings = async (): Promise<Settings> => {
|
||||
showTitles: true,
|
||||
showStats: true,
|
||||
},
|
||||
defaultAudioLanguage: null,
|
||||
defaultSubtitleLanguage: null,
|
||||
showHomeTitles: true,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -25,8 +25,8 @@ export const getStreamUrl = async ({
|
||||
userId: string | null | undefined;
|
||||
startTimeTicks: number;
|
||||
maxStreamingBitrate?: number;
|
||||
sessionData: PlaybackInfoResponse;
|
||||
deviceProfile: any;
|
||||
sessionData?: PlaybackInfoResponse;
|
||||
deviceProfile?: any;
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
forceDirectPlay?: boolean;
|
||||
@@ -72,16 +72,12 @@ export const getStreamUrl = async ({
|
||||
throw new Error("No media source");
|
||||
}
|
||||
|
||||
if (!sessionData.PlaySessionId) {
|
||||
throw new Error("no PlaySessionId");
|
||||
}
|
||||
|
||||
let url: string | null | undefined;
|
||||
|
||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||
if (item.MediaType === "Video") {
|
||||
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}`;
|
||||
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") {
|
||||
console.log("Using direct stream for audio!");
|
||||
const searchParams = new URLSearchParams({
|
||||
@@ -94,7 +90,9 @@ export const getStreamUrl = async ({
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
PlaySessionId: sessionData.PlaySessionId,
|
||||
PlaySessionId: sessionData?.PlaySessionId
|
||||
? sessionData.PlaySessionId
|
||||
: "",
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { AxiosError } from "axios";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
interface PlaybackStoppedParams {
|
||||
api: Api | null | undefined;
|
||||
@@ -27,17 +28,23 @@ export const reportPlaybackStopped = async ({
|
||||
if (!positionTicks || positionTicks === 0) return;
|
||||
|
||||
if (!api) {
|
||||
console.error("Missing api");
|
||||
writeToLog("WARN", "Could not report playback stopped due to missing api");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.error("Missing sessionId", sessionId);
|
||||
writeToLog(
|
||||
"WARN",
|
||||
"Could not report playback stopped due to missing session id"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
console.error("Missing itemId");
|
||||
writeToLog(
|
||||
"WARN",
|
||||
"Could not report playback progress due to missing item id"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user