forked from Ninjalama/streamyfin_mirror
Compare commits
64 Commits
v0.10.1
...
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 | ||
|
|
d4252682be | ||
|
|
c31eb498ea | ||
|
|
7b9bad630f | ||
|
|
10e0a45cd4 | ||
|
|
fb0b9c83ae | ||
|
|
58b72b8b75 | ||
|
|
b771c90dfc | ||
|
|
7fa729f89f | ||
|
|
682ab4dd31 | ||
|
|
3d73f604ac | ||
|
|
318940f7c4 | ||
|
|
2ee6573a90 | ||
|
|
3bd1177c45 | ||
|
|
080de162ec | ||
|
|
cca28d7e21 | ||
|
|
e29b3787b9 | ||
|
|
ef8bb3e717 | ||
|
|
61cb205f93 | ||
|
|
ffea51ccb0 | ||
|
|
0a53cf6b17 | ||
|
|
32ac4ec62f | ||
|
|
30678813b4 | ||
|
|
68cfe99421 | ||
|
|
55b1c3ae45 | ||
|
|
6c1db4bbb9 | ||
|
|
bbaab1994a | ||
|
|
8c0e7f7db8 | ||
|
|
8b3b492f5e | ||
|
|
78189c8246 | ||
|
|
b22ffee707 | ||
|
|
688c343a35 | ||
|
|
fb6e3dc690 | ||
|
|
e9783d293d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ build-*
|
||||
*.mp4
|
||||
build-*
|
||||
Streamyfin.app
|
||||
package-lock.json
|
||||
|
||||
/ios
|
||||
/android
|
||||
|
||||
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
|
||||
|
||||
|
||||
11
app.json
11
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.10.1",
|
||||
"version": "0.12.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 30,
|
||||
"versionCode": 36,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
@@ -71,6 +71,13 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"./plugins/withAndroidMainActivityAttributes",
|
||||
{
|
||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
||||
}
|
||||
],
|
||||
["./plugins/withExpandedController.js"],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -65,15 +65,14 @@ const downloads: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View
|
||||
className="px-4 py-4"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="px-4 py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4">
|
||||
<View>
|
||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -27,20 +27,21 @@ export default function settings() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View
|
||||
className="p-4 flex flex-col gap-y-4 pb-12"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold text-2xl">Information</Text>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
<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 />
|
||||
@@ -72,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>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,15 @@ import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -28,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);
|
||||
@@ -56,21 +53,6 @@ const page: React.FC = () => {
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "PremiereDate",
|
||||
value: "Premiere Date",
|
||||
},
|
||||
]);
|
||||
setSortOrder([
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
@@ -88,6 +70,18 @@ const page: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
|
||||
if (!collection) return;
|
||||
|
||||
// Convert the DisplayOrder to SortByOption
|
||||
const displayOrder = collection.DisplayOrder as ItemSortBy;
|
||||
const sortByOption = displayOrder
|
||||
? SortByOption[displayOrder as keyof typeof SortByOption] ||
|
||||
SortByOption.PremiereDate
|
||||
: SortByOption.PremiereDate;
|
||||
|
||||
setSortBy([sortByOption]);
|
||||
}, [navigation, collection]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
@@ -103,8 +97,9 @@ const page: React.FC = () => {
|
||||
parentId: collectionId,
|
||||
limit: 18,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||
sortBy: [sortBy[0]],
|
||||
sortOrder: [sortOrder[0]],
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
@@ -194,7 +189,8 @@ const page: React.FC = () => {
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemPoster item={item} />
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</MemoizedTouchableItemRouter>
|
||||
@@ -216,6 +212,13 @@ const page: React.FC = () => {
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
@@ -307,13 +310,15 @@ const page: React.FC = () => {
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions}
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) => item.value}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -325,13 +330,15 @@ const page: React.FC = () => {
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions}
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) => item.value}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -369,6 +376,13 @@ const page: React.FC = () => {
|
||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||
</View>
|
||||
}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
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 {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
|
||||
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 MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { Loader } from "@/components/Loader";
|
||||
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,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -43,7 +39,6 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
@@ -54,17 +49,61 @@ const Page = () => {
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
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] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
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(() => {
|
||||
@@ -73,38 +112,7 @@ const Page = () => {
|
||||
if (screenWidth < 960) return 6;
|
||||
if (screenWidth < 1280) return 7;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "SortName",
|
||||
value: "Name",
|
||||
},
|
||||
]);
|
||||
setSortOrder([
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
}, [screenWidth, orientation]);
|
||||
|
||||
const { data: library, isLoading: isLibraryLoading } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
@@ -133,8 +141,8 @@ const Page = () => {
|
||||
parentId: libraryId,
|
||||
limit: 36,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: false,
|
||||
imageTypeLimit: 1,
|
||||
@@ -225,7 +233,8 @@ const Page = () => {
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</MemoizedTouchableItemRouter>
|
||||
@@ -338,13 +347,15 @@ const Page = () => {
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions}
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) => item.value}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -356,13 +367,15 @@ const Page = () => {
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions}
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) => item.value}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -417,6 +430,7 @@ const Page = () => {
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
extraData={orientation}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={244}
|
||||
numColumns={getNumberOfColumns()}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -13,11 +13,12 @@ import { Stack, useRouter } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import * as Linking from "expo-linking";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function RootLayout() {
|
||||
|
||||
function Layout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||
|
||||
useKeepAwake();
|
||||
|
||||
@@ -71,8 +73,24 @@ function Layout() {
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
console.log(event.orientationInfo.orientation);
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const url = Linking.useURL();
|
||||
const router = useRouter();
|
||||
|
||||
if (url) {
|
||||
const { hostname, path, queryParams } = Linking.parse(url);
|
||||
|
||||
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
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useEffect } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
|
||||
@@ -25,6 +27,7 @@ export const Chromecast: React.FC<Props> = ({
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -38,21 +41,47 @@ export const Chromecast: React.FC<Props> = ({
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<View
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||
{...props}
|
||||
>
|
||||
<CastButton style={{ tintColor: "white", height, width }} />
|
||||
</View>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<BlurView
|
||||
intensity={100}
|
||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<CastButton style={{ tintColor: "white", height, width }} />
|
||||
</BlurView>
|
||||
<BlurView
|
||||
intensity={100}
|
||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
@@ -54,11 +54,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const userCanDownload = useMemo(() => {
|
||||
return user?.Policy?.EnableContentDownloading;
|
||||
}, [user]);
|
||||
|
||||
/**
|
||||
* Bottom sheet
|
||||
*/
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["50%"], []);
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
@@ -145,15 +148,13 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
item.Id
|
||||
}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSource.TranscodingUrl) {
|
||||
} else if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
} else {
|
||||
throw new Error("No transcoding url");
|
||||
}
|
||||
|
||||
if (!url) throw new Error("No url");
|
||||
|
||||
return await startRemuxing(url);
|
||||
}, [
|
||||
api,
|
||||
@@ -288,14 +289,21 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={() => {
|
||||
closeModal();
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await initiateDownload();
|
||||
},
|
||||
item,
|
||||
});
|
||||
if (userCanDownload === true) {
|
||||
closeModal();
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await initiateDownload();
|
||||
},
|
||||
item,
|
||||
});
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Disabled",
|
||||
"This user is not allowed to download files."
|
||||
);
|
||||
}
|
||||
}}
|
||||
color="purple"
|
||||
>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
@@ -25,23 +27,22 @@ import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import { Loader } from "./Loader";
|
||||
import { set } from "lodash";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
|
||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -61,7 +62,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const [loadingImage, setLoadingImage] = useState(true);
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
@@ -102,7 +102,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const headerHeightRef = useRef(0);
|
||||
const headerHeightRef = useRef(400);
|
||||
|
||||
const {
|
||||
data: item,
|
||||
@@ -166,6 +166,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
}
|
||||
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
||||
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
||||
else headerHeightRef.current = 400;
|
||||
}, [item]);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
@@ -232,12 +233,22 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
});
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
const themeImageColorSource = useMemo(() => {
|
||||
if (!api || !item) return;
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
useImageColors(themeImageColorSource?.uri);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(
|
||||
isLoading || isFetching || loadingImage || (logoUrl && loadingLogo)
|
||||
);
|
||||
}, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]);
|
||||
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
|
||||
}, [isLoading, isFetching, loadingLogo, logoUrl]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -262,6 +273,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||
{localItem && (
|
||||
<ItemImage
|
||||
useThemeColor
|
||||
variant={
|
||||
localItem.Type === "Movie" && logoUrl
|
||||
? "Backdrop"
|
||||
@@ -272,8 +284,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
onLoad={() => setLoadingImage(false)}
|
||||
onError={() => setLoadingImage(false)}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
19
components/List.tsx
Normal file
19
components/List.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const List: React.FC<PropsWithChildren<Props>> = ({
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,13 @@
|
||||
import { PropsWithChildren, ReactNode } from "react";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
if (mediaSources?.length) onChange(mediaSources[0]);
|
||||
}, [mediaSources]);
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
if (name && name.length > 40)
|
||||
return (
|
||||
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
|
||||
);
|
||||
return name;
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
@@ -69,7 +77,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
onChange(source);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{source.Name}</DropdownMenu.ItemTitle>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{name(source.Name)}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
|
||||
@@ -1,115 +1,160 @@
|
||||
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";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
interpolateColor,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { useAtom } from "jotai";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
interpolateColor,
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
const MIN_PLAYBACK_WIDTH = 15;
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
const [color] = useAtom(itemThemeColorAtom);
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
// Create a shared value for animation progress
|
||||
const progress = useSharedValue(0);
|
||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||
|
||||
// Create shared values for start and end colors
|
||||
const startColor = useSharedValue(color);
|
||||
const endColor = useSharedValue(color);
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(memoizedColor);
|
||||
const startColor = useSharedValue(memoizedColor);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
// When color changes, update end color and animate progress
|
||||
endColor.value = color;
|
||||
progress.value = 0; // Reset progress
|
||||
progress.value = withTiming(1, { duration: 300 }); // Animate to 1 over 500ms
|
||||
}, [color]);
|
||||
|
||||
// Animated style for primary color
|
||||
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[startColor.value.average, endColor.value.average]
|
||||
),
|
||||
}));
|
||||
|
||||
// Animated style for text color
|
||||
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||
color: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[startColor.value.text, endColor.value.text]
|
||||
),
|
||||
}));
|
||||
|
||||
// Update start color after animation completes
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
startColor.value = color;
|
||||
}, 500); // Should match the duration in withTiming
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [color]);
|
||||
const directStream = useMemo(() => {
|
||||
return !url?.includes("m3u8");
|
||||
}, []);
|
||||
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
|
||||
if (!client) {
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
const isOpeningCurrentlyPlayingMedia =
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: url,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
// If we're opening a currently playing item, don't restart the media.
|
||||
// Instead just open controls.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
CastContext.showExpandedControls();
|
||||
return;
|
||||
}
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: url,
|
||||
contentType: "video/mp4",
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
startTime: 0,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
@@ -123,56 +168,154 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const playbackPercent = useMemo(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (!userData) return 0;
|
||||
const PlaybackPositionTicks = userData.PlaybackPositionTicks;
|
||||
if (!PlaybackPositionTicks) return 0;
|
||||
return (PlaybackPositionTicks / item.RunTimeTicks) * 100;
|
||||
}, [item]);
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
|
||||
const userData = memoizedItem.UserData;
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
}, [memoizedItem]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => derivedTargetWidth.value,
|
||||
(newWidth) => {
|
||||
targetWidth.value = newWidth;
|
||||
widthProgress.value = 0;
|
||||
widthProgress.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||
});
|
||||
},
|
||||
[item]
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => memoizedColor,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
colorChangeProgress.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[memoizedColor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout_2 = setTimeout(() => {
|
||||
startColor.value = memoizedColor;
|
||||
startWidth.value = targetWidth.value;
|
||||
}, ANIMATION_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout_2);
|
||||
};
|
||||
}, [memoizedColor, memoizedItem]);
|
||||
|
||||
/**
|
||||
* ANIMATED STYLES
|
||||
*/
|
||||
const animatedAverageStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.average, endColor.value.average]
|
||||
),
|
||||
}));
|
||||
|
||||
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
),
|
||||
}));
|
||||
|
||||
const animatedWidthStyle = useAnimatedStyle(() => ({
|
||||
width: `${interpolate(
|
||||
widthProgress.value,
|
||||
[0, 1],
|
||||
[startWidth.value, targetWidth.value]
|
||||
)}%`,
|
||||
}));
|
||||
|
||||
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||
color: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.text, endColor.value.text]
|
||||
),
|
||||
}));
|
||||
/**
|
||||
* *********************
|
||||
*/
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} className="relative" {...props}>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
{
|
||||
width:
|
||||
playbackPercent === 0
|
||||
? "100%"
|
||||
: `${Math.max(playbackPercent, 15)}%`,
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
|
||||
/>
|
||||
<Animated.View
|
||||
style={[animatedPrimaryStyle]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl "
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: color.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;
|
||||
|
||||
|
||||
@@ -1,95 +1,83 @@
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image, ImageProps, ImageSource } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface Props extends ImageProps {
|
||||
item: BaseItemDto;
|
||||
variant?: "Backdrop" | "Primary" | "Thumb" | "Logo";
|
||||
variant?:
|
||||
| "Primary"
|
||||
| "Backdrop"
|
||||
| "ParentBackdrop"
|
||||
| "ParentLogo"
|
||||
| "Logo"
|
||||
| "AlbumPrimary"
|
||||
| "SeriesPrimary"
|
||||
| "Screenshot"
|
||||
| "Thumb";
|
||||
quality?: number;
|
||||
width?: number;
|
||||
useThemeColor?: boolean;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
export const ItemImage: React.FC<Props> = ({
|
||||
item,
|
||||
variant,
|
||||
variant = "Primary",
|
||||
quality = 90,
|
||||
width = 1000,
|
||||
useThemeColor = false,
|
||||
onError,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
let tag: string | null | undefined;
|
||||
let blurhash: string | null | undefined;
|
||||
let src: ImageSource | null = null;
|
||||
|
||||
switch (variant) {
|
||||
case "Backdrop":
|
||||
if (item.Type === "Episode") {
|
||||
tag = item.ParentBackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
tag = item.BackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Primary":
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Primary?.[tag];
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Thumb":
|
||||
tag = item.ImageTags?.["Thumb"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||
};
|
||||
break;
|
||||
if (!api) {
|
||||
onError && onError();
|
||||
return;
|
||||
}
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant,
|
||||
quality,
|
||||
width,
|
||||
});
|
||||
}, [api, item, quality, variant, width]);
|
||||
|
||||
return src;
|
||||
}, [item.ImageTags]);
|
||||
|
||||
useImageColors(source?.uri);
|
||||
// return placeholder icon if no source
|
||||
if (!source?.uri)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
placeholder={{
|
||||
blurhash: source?.blurhash,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: source?.uri,
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
@@ -55,7 +55,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}
|
||||
|
||||
if (item.Type === "UserView") {
|
||||
Alert.alert("Not implemented");
|
||||
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
|
||||
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{items.length}</Text>
|
||||
</View>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const FilterButton = <T,>({
|
||||
queryFn,
|
||||
queryKey,
|
||||
set,
|
||||
values,
|
||||
values, // selected values
|
||||
title,
|
||||
renderItemLabel,
|
||||
searchFilter,
|
||||
|
||||
@@ -186,7 +186,7 @@ export const FilterSheet = <T,>({
|
||||
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
||||
>
|
||||
<Text>{renderItemLabel(item)}</Text>
|
||||
{values.includes(item) ? (
|
||||
{values.some((i) => i === item) ? (
|
||||
<Ionicons name="radio-button-on" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="radio-button-off" size={24} color="white" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
53
components/posters/ItemPoster.tsx
Normal file
53
components/posters/ItemPoster.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { WatchedIndicator } from "../WatchedIndicator";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
}
|
||||
|
||||
export const ItemPoster: React.FC<Props> = ({
|
||||
item,
|
||||
showProgress,
|
||||
...props
|
||||
}) => {
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
if (item.Type === "Movie" || item.Type === "Series" || item.Type === "BoxSet")
|
||||
return (
|
||||
<View
|
||||
className="relative rounded-lg overflow-hidden border border-neutral-900"
|
||||
{...props}
|
||||
>
|
||||
<ItemImage
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className="h-1 bg-red-600 w-full"></View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="rounded-lg w-full aspect-square overflow-hidden border border-neutral-900"
|
||||
{...props}
|
||||
>
|
||||
<ItemImage className="w-full aspect-square" item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
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.1",
|
||||
"channel": "0.12.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.10.1",
|
||||
"channel": "0.12.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
|
||||
export const useImageColors = (uri: string | undefined | null) => {
|
||||
export const useImageColors = (
|
||||
uri: string | undefined | null,
|
||||
disabled = false
|
||||
) => {
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
if (uri) {
|
||||
getColors(uri, {
|
||||
fallback: "#fff",
|
||||
@@ -38,5 +42,5 @@ export const useImageColors = (uri: string | undefined | null) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [uri, setPrimaryColor]);
|
||||
}, [uri, setPrimaryColor, disabled]);
|
||||
};
|
||||
|
||||
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",
|
||||
|
||||
42
plugins/withAndroidMainActivityAttributes.js
Normal file
42
plugins/withAndroidMainActivityAttributes.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { withAndroidManifest } = require("@expo/config-plugins");
|
||||
|
||||
function addAttributesToMainActivity(androidManifest, attributes) {
|
||||
const { manifest } = androidManifest;
|
||||
|
||||
if (!Array.isArray(manifest["application"])) {
|
||||
console.warn("withAndroidMainActivityAttributes: No application array in manifest?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
const application = manifest["application"].find(
|
||||
(item) => item.$["android:name"] === ".MainApplication"
|
||||
);
|
||||
if (!application) {
|
||||
console.warn("withAndroidMainActivityAttributes: No .MainApplication?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
if (!Array.isArray(application["activity"])) {
|
||||
console.warn("withAndroidMainActivityAttributes: No activity array in .MainApplication?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
const activity = application["activity"].find(
|
||||
(item) => item.$["android:name"] === ".MainActivity"
|
||||
);
|
||||
if (!activity) {
|
||||
console.warn("withAndroidMainActivityAttributes: No .MainActivity?");
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
activity.$ = { ...activity.$, ...attributes };
|
||||
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
module.exports = function withAndroidMainActivityAttributes(config, attributes) {
|
||||
return withAndroidManifest(config, (config) => {
|
||||
config.modResults = addAttributesToMainActivity(config.modResults, attributes);
|
||||
return config;
|
||||
});
|
||||
};
|
||||
20
plugins/withExpandedController.js
Normal file
20
plugins/withExpandedController.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const { withAppDelegate } = require("@expo/config-plugins");
|
||||
|
||||
const withExpandedController = (config) => {
|
||||
return withAppDelegate(config, async (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
// Looking for the initialProps string inside didFinishLaunchingWithOptions,
|
||||
// and injecting expanded controller config.
|
||||
// Should be updated once there is an expo config option - see https://github.com/react-native-google-cast/react-native-google-cast/discussions/537
|
||||
const injectionIndex = contents.indexOf("self.initialProps = @{};");
|
||||
config.modResults.contents =
|
||||
contents.substring(0, injectionIndex) +
|
||||
`\n [GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true; \n` +
|
||||
contents.substring(injectionIndex);
|
||||
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = withExpandedController;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
@@ -62,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.10.1" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.12.0" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
})
|
||||
);
|
||||
@@ -75,12 +76,28 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
|
||||
useQuery({
|
||||
queryKey: ["user", api],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserApi(api).getCurrentUser();
|
||||
if (response.data) setUser(response.data);
|
||||
return user;
|
||||
},
|
||||
enabled: !!api,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 1000 * 60,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (!deviceId) return {};
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.10.1"`,
|
||||
}, 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,61 +331,185 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setIsFullscreen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId || !api?.accessToken) return;
|
||||
const seek = useCallback((ticks: number) => {
|
||||
const time = ticks / 10000000;
|
||||
videoRef.current?.seek(time);
|
||||
}, []);
|
||||
|
||||
const url = `wss://${api?.basePath
|
||||
useEffect(() => {
|
||||
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();
|
||||
@@ -297,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,
|
||||
@@ -318,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,50 +1,133 @@
|
||||
import {
|
||||
ItemFilter,
|
||||
ItemSortBy,
|
||||
NameGuidPair,
|
||||
SortOrder,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
|
||||
export enum SortByOption {
|
||||
Default = "Default",
|
||||
SortName = "SortName",
|
||||
CommunityRating = "CommunityRating",
|
||||
CriticRating = "CriticRating",
|
||||
DateCreated = "DateCreated",
|
||||
DatePlayed = "DatePlayed",
|
||||
PlayCount = "PlayCount",
|
||||
ProductionYear = "ProductionYear",
|
||||
Runtime = "Runtime",
|
||||
OfficialRating = "OfficialRating",
|
||||
PremiereDate = "PremiereDate",
|
||||
StartDate = "StartDate",
|
||||
IsUnplayed = "IsUnplayed",
|
||||
IsPlayed = "IsPlayed",
|
||||
AirTime = "AirTime",
|
||||
Studio = "Studio",
|
||||
IsFavoriteOrLiked = "IsFavoriteOrLiked",
|
||||
Random = "Random",
|
||||
}
|
||||
|
||||
export enum SortOrderOption {
|
||||
Ascending = "Ascending",
|
||||
Descending = "Descending",
|
||||
}
|
||||
|
||||
export const sortOptions: {
|
||||
key: ItemSortBy;
|
||||
key: SortByOption;
|
||||
value: string;
|
||||
}[] = [
|
||||
{ key: "SortName", value: "Name" },
|
||||
{ key: "CommunityRating", value: "Community Rating" },
|
||||
{ key: "CriticRating", value: "Critics Rating" },
|
||||
{ key: "DateCreated", value: "Date Added" },
|
||||
// Only works for shows (last episode added) keeping for future ref.
|
||||
// { key: "DateLastContentAdded", value: "Content Added" },
|
||||
{ key: "DatePlayed", value: "Date Played" },
|
||||
{ key: "PlayCount", value: "Play Count" },
|
||||
{ key: "ProductionYear", value: "Production Year" },
|
||||
{ key: "Runtime", value: "Runtime" },
|
||||
{ key: "OfficialRating", value: "Official Rating" },
|
||||
{ key: "PremiereDate", value: "Premiere Date" },
|
||||
{ key: "StartDate", value: "Start Date" },
|
||||
{ key: "IsUnplayed", value: "Is Unplayed" },
|
||||
{ key: "IsPlayed", value: "Is Played" },
|
||||
// Broken in JF
|
||||
// { key: "VideoBitRate", value: "Video Bit Rate" },
|
||||
{ key: "AirTime", value: "Air Time" },
|
||||
{ key: "Studio", value: "Studio" },
|
||||
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
|
||||
{ key: "Random", value: "Random" },
|
||||
{ key: SortByOption.Default, value: "Default" },
|
||||
{ key: SortByOption.SortName, value: "Name" },
|
||||
{ key: SortByOption.CommunityRating, value: "Community Rating" },
|
||||
{ key: SortByOption.CriticRating, value: "Critics Rating" },
|
||||
{ key: SortByOption.DateCreated, value: "Date Added" },
|
||||
{ key: SortByOption.DatePlayed, value: "Date Played" },
|
||||
{ key: SortByOption.PlayCount, value: "Play Count" },
|
||||
{ key: SortByOption.ProductionYear, value: "Production Year" },
|
||||
{ key: SortByOption.Runtime, value: "Runtime" },
|
||||
{ key: SortByOption.OfficialRating, value: "Official Rating" },
|
||||
{ key: SortByOption.PremiereDate, value: "Premiere Date" },
|
||||
{ key: SortByOption.StartDate, value: "Start Date" },
|
||||
{ key: SortByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: SortByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: SortByOption.AirTime, value: "Air Time" },
|
||||
{ key: SortByOption.Studio, value: "Studio" },
|
||||
{ key: SortByOption.IsFavoriteOrLiked, value: "Is Favorite Or Liked" },
|
||||
{ key: SortByOption.Random, value: "Random" },
|
||||
];
|
||||
|
||||
export const sortOrderOptions: {
|
||||
key: SortOrder;
|
||||
key: SortOrderOption;
|
||||
value: string;
|
||||
}[] = [
|
||||
{ key: "Ascending", value: "Ascending" },
|
||||
{ key: "Descending", value: "Descending" },
|
||||
{ key: SortOrderOption.Ascending, value: "Ascending" },
|
||||
{ key: SortOrderOption.Descending, value: "Descending" },
|
||||
];
|
||||
|
||||
export const genreFilterAtom = atom<string[]>([]);
|
||||
export const tagsFilterAtom = atom<string[]>([]);
|
||||
export const yearFilterAtom = atom<string[]>([]);
|
||||
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
|
||||
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
|
||||
sortOrderOptions[0],
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
6
utils/atoms/orientation.ts
Normal file
6
utils/atoms/orientation.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const orientationAtom = atom<number>(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
87
utils/getItemImage.ts
Normal file
87
utils/getItemImage.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ImageSource } from "expo-image";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
api: Api;
|
||||
quality?: number;
|
||||
width?: number;
|
||||
variant?:
|
||||
| "Primary"
|
||||
| "Backdrop"
|
||||
| "ParentBackdrop"
|
||||
| "ParentLogo"
|
||||
| "Logo"
|
||||
| "AlbumPrimary"
|
||||
| "SeriesPrimary"
|
||||
| "Screenshot"
|
||||
| "Thumb";
|
||||
}
|
||||
|
||||
export const getItemImage = ({
|
||||
item,
|
||||
api,
|
||||
variant = "Primary",
|
||||
quality = 90,
|
||||
width = 1000,
|
||||
}: Props) => {
|
||||
if (!api) return null;
|
||||
|
||||
let tag: string | null | undefined;
|
||||
let blurhash: string | null | undefined;
|
||||
let src: ImageSource | null = null;
|
||||
|
||||
switch (variant) {
|
||||
case "Backdrop":
|
||||
if (item.Type === "Episode") {
|
||||
tag = item.ParentBackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
tag = item.BackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}&width=${width}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Primary":
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Primary?.[tag];
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Thumb":
|
||||
tag = item.ImageTags?.["Thumb"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}&width=${width}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}&width=${width}`,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (!src?.uri) return null;
|
||||
|
||||
return src;
|
||||
};
|
||||
@@ -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,14 +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!");
|
||||
return `${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({
|
||||
@@ -92,21 +90,23 @@ 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",
|
||||
});
|
||||
return `${
|
||||
url = `${
|
||||
api.basePath
|
||||
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
} else if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
}
|
||||
|
||||
if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
return `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
} else {
|
||||
throw new Error("No transcoding url");
|
||||
}
|
||||
if (!url) throw new Error("No url");
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
@@ -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