mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-16 17:12:23 +00:00
Compare commits
62 Commits
v0.10.2
...
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 | ||
|
|
b22ffee707 | ||
|
|
688c343a35 | ||
|
|
fb6e3dc690 | ||
|
|
e9783d293d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ build-*
|
|||||||
*.mp4
|
*.mp4
|
||||||
build-*
|
build-*
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
/ios
|
/ios
|
||||||
/android
|
/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.
|
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">
|
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||||
<img width=100 src="./assets/images/screenshots/1.jpg" />
|
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||||
<img width=100 src="./assets/images/screenshots/3.jpg" />
|
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||||
<img width=100 src="./assets/images/screenshots/4.jpg" />
|
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||||
<img width=100 src="./assets/images/screenshots/5.jpg" />
|
|
||||||
<img width=100 src="./assets/images/screenshots/7.jpg" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
@@ -26,7 +25,7 @@ Streamyfin includes some exciting experimental features like media downloading a
|
|||||||
|
|
||||||
### Downloading
|
### 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
|
### Chromecast
|
||||||
|
|
||||||
@@ -34,19 +33,19 @@ Chromecast support is still in development, and we're working on improving it. C
|
|||||||
|
|
||||||
## Plugins
|
## 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
|
### Collection rows
|
||||||
|
|
||||||
Jellyfin collections can be shown as rows or carousel on the home screen.
|
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_promoted: will make the collection a row at home
|
||||||
- sf_carousel: Wil make the collection an carousel on 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.
|
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
|
||||||
|
|
||||||
### Jellysearch
|
### Jellysearch
|
||||||
@@ -90,8 +89,8 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
### Development info
|
### Development info
|
||||||
|
|
||||||
1. Use node `20`
|
1. Use node `20`
|
||||||
2. Install deps `bun i`
|
2. Install dependencies `bun i`
|
||||||
3. `Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||||
|
|
||||||
## Extended chromecast controls
|
## Extended chromecast controls
|
||||||
|
|
||||||
|
|||||||
11
app.json
11
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.10.2",
|
"version": "0.12.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 31,
|
"versionCode": 36,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon.png"
|
"foregroundImage": "./assets/images/icon.png"
|
||||||
},
|
},
|
||||||
@@ -71,6 +71,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withAndroidMainActivityAttributes",
|
||||||
|
{
|
||||||
|
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["./plugins/withExpandedController.js"],
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
@@ -32,6 +32,16 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/syncplay");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="people" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
<Chromecast />
|
<Chromecast />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -58,6 +68,13 @@ export default function IndexLayout() {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="syncplay"
|
||||||
|
options={{
|
||||||
|
title: "Syncplay",
|
||||||
|
presentation: "modal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
@@ -5,6 +6,8 @@ import { Loader } from "@/components/Loader";
|
|||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getItemsApi,
|
getItemsApi,
|
||||||
@@ -18,7 +21,13 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
type BaseSection = {
|
type BaseSection = {
|
||||||
@@ -41,6 +50,7 @@ type Section = ScrollingCollectionListSection | MediaListSection;
|
|||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -49,6 +59,14 @@ export default function index() {
|
|||||||
const [settings, _] = useSettings();
|
const [settings, _] = useSettings();
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
@@ -187,6 +205,7 @@ export default function index() {
|
|||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie"],
|
||||||
parentId: movieCollectionId,
|
parentId: movieCollectionId,
|
||||||
})
|
})
|
||||||
).data || [],
|
).data || [],
|
||||||
@@ -203,6 +222,7 @@ export default function index() {
|
|||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Series"],
|
||||||
parentId: tvShowCollectionId,
|
parentId: tvShowCollectionId,
|
||||||
})
|
})
|
||||||
).data || [],
|
).data || [],
|
||||||
@@ -226,15 +246,20 @@ export default function index() {
|
|||||||
{
|
{
|
||||||
title: "Suggested Episodes",
|
title: "Suggested Episodes",
|
||||||
queryKey: ["suggestedEpisodes", user?.Id],
|
queryKey: ["suggestedEpisodes", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () => {
|
||||||
(
|
try {
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
const suggestions = await getSuggestions(api, user.Id);
|
||||||
userId: user?.Id,
|
const nextUpPromises = suggestions.map((series) =>
|
||||||
limit: 10,
|
getNextUp(api, user.Id, series.Id)
|
||||||
mediaType: ["Video"],
|
);
|
||||||
type: ["Episode"],
|
const nextUpResults = await Promise.all(nextUpPromises);
|
||||||
})
|
|
||||||
).data.Items || [],
|
return nextUpResults.filter((item) => item !== null) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
},
|
},
|
||||||
@@ -248,28 +273,47 @@ export default function index() {
|
|||||||
mediaListCollections,
|
mediaListCollections,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// if (isConnected === false) {
|
if (isConnected === false) {
|
||||||
// return (
|
return (
|
||||||
// <View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
<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-3xl font-bold mb-2">No Internet</Text>
|
||||||
// <Text className="text-center opacity-70">
|
<Text className="text-center opacity-70">
|
||||||
// No worries, you can still watch{"\n"}downloaded content.
|
No worries, you can still watch{"\n"}downloaded content.
|
||||||
// </Text>
|
</Text>
|
||||||
// <View className="mt-4">
|
<View className="mt-4">
|
||||||
// <Button
|
<Button
|
||||||
// color="purple"
|
color="purple"
|
||||||
// onPress={() => router.push("/(auth)/downloads")}
|
onPress={() => router.push("/(auth)/downloads")}
|
||||||
// justify="center"
|
justify="center"
|
||||||
// iconRight={
|
iconRight={
|
||||||
// <Ionicons name="arrow-forward" size={20} color="white" />
|
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||||
// }
|
}
|
||||||
// >
|
>
|
||||||
// Go to downloads
|
Go to downloads
|
||||||
// </Button>
|
</Button>
|
||||||
// </View>
|
<Button
|
||||||
// </View>
|
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();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -333,3 +377,30 @@ export default function index() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to get suggestions
|
||||||
|
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||||
|
if (!userId) return [];
|
||||||
|
const response = await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Unknown"],
|
||||||
|
type: ["Series"],
|
||||||
|
});
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get the next up TV show for a series
|
||||||
|
async function getNextUp(
|
||||||
|
api: Api,
|
||||||
|
userId: string | undefined,
|
||||||
|
seriesId: string | undefined
|
||||||
|
) {
|
||||||
|
if (!userId || !seriesId) return null;
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId,
|
||||||
|
seriesId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return response.data.Items?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ export default function settings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<Text className="font-bold text-2xl">Information</Text>
|
<View>
|
||||||
|
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem title="User" subTitle={user?.Name} />
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
@@ -71,26 +73,27 @@ export default function settings() {
|
|||||||
Delete all logs
|
Delete all logs
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
<View>
|
||||||
<Text className="font-bold text-2xl">Logs</Text>
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
{logs?.map((log, index) => (
|
{logs?.map((log, index) => (
|
||||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
mb-1
|
mb-1
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{log.level}
|
{log.level}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs">{log.message}</Text>
|
<Text className="text-xs">{log.message}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{logs?.length === 0 && (
|
{logs?.length === 0 && (
|
||||||
<Text className="opacity-50">No logs available</Text>
|
<Text className="opacity-50">No logs available</Text>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</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 { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
sortByAtom,
|
sortByAtom,
|
||||||
|
SortByOption,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
sortOrderAtom,
|
sortOrderAtom,
|
||||||
|
SortOrderOption,
|
||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
|
ItemSortBy,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -28,13 +31,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
@@ -56,21 +53,6 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setSortBy([
|
|
||||||
{
|
|
||||||
key: "PremiereDate",
|
|
||||||
value: "Premiere Date",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setSortOrder([
|
|
||||||
{
|
|
||||||
key: "Ascending",
|
|
||||||
value: "Ascending",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -88,6 +70,18 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
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]);
|
}, [navigation, collection]);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
@@ -103,8 +97,9 @@ const page: React.FC = () => {
|
|||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: 18,
|
limit: 18,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||||
sortOrder: [sortOrder[0].key],
|
sortBy: [sortBy[0]],
|
||||||
|
sortOrder: [sortOrder[0]],
|
||||||
fields: [
|
fields: [
|
||||||
"ItemCounts",
|
"ItemCounts",
|
||||||
"PrimaryImageAspectRatio",
|
"PrimaryImageAspectRatio",
|
||||||
@@ -194,7 +189,8 @@ const page: React.FC = () => {
|
|||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} />
|
<ItemPoster item={item} />
|
||||||
|
{/* <MoviePoster item={item} /> */}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</MemoizedTouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
@@ -216,6 +212,13 @@ const page: React.FC = () => {
|
|||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
}}
|
}}
|
||||||
|
extraData={[
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
]}
|
||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
key: "reset",
|
key: "reset",
|
||||||
@@ -307,13 +310,15 @@ const page: React.FC = () => {
|
|||||||
className="mr-1"
|
className="mr-1"
|
||||||
collectionId={collectionId}
|
collectionId={collectionId}
|
||||||
queryKey="sortBy"
|
queryKey="sortBy"
|
||||||
queryFn={async () => sortOptions}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title="Sort By"
|
title="Sort By"
|
||||||
renderItemLabel={(item) => item.value}
|
renderItemLabel={(item) =>
|
||||||
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
searchFilter={(item, search) =>
|
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"
|
className="mr-1"
|
||||||
collectionId={collectionId}
|
collectionId={collectionId}
|
||||||
queryKey="sortOrder"
|
queryKey="sortOrder"
|
||||||
queryFn={async () => sortOrderOptions}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
title="Sort Order"
|
title="Sort Order"
|
||||||
renderItemLabel={(item) => item.value}
|
renderItemLabel={(item) =>
|
||||||
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
searchFilter={(item, search) =>
|
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>
|
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
|
extraData={[
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
]}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
|||||||
@@ -1,41 +1,37 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
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 * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||||
useCallback,
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
FlatList,
|
|
||||||
RefreshControl,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
|
getSortByPreference,
|
||||||
|
getSortOrderPreference,
|
||||||
sortByAtom,
|
sortByAtom,
|
||||||
|
SortByOption,
|
||||||
|
sortByPreferenceAtom,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
sortOrderAtom,
|
sortOrderAtom,
|
||||||
|
SortOrderOption,
|
||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
|
sortOrderPreferenceAtom,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -43,7 +39,6 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
@@ -54,17 +49,61 @@ const Page = () => {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
const [orientation] = useAtom(orientationAtom);
|
||||||
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
|
sortOrderPreferenceAtom
|
||||||
|
);
|
||||||
|
|
||||||
const [orientation, setOrientation] = useState(
|
useEffect(() => {
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
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(() => {
|
const getNumberOfColumns = useCallback(() => {
|
||||||
@@ -73,38 +112,7 @@ const Page = () => {
|
|||||||
if (screenWidth < 960) return 6;
|
if (screenWidth < 960) return 6;
|
||||||
if (screenWidth < 1280) return 7;
|
if (screenWidth < 1280) return 7;
|
||||||
return 6;
|
return 6;
|
||||||
}, [screenWidth]);
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data: library, isLoading: isLibraryLoading } = useQuery({
|
const { data: library, isLoading: isLibraryLoading } = useQuery({
|
||||||
queryKey: ["library", libraryId],
|
queryKey: ["library", libraryId],
|
||||||
@@ -133,8 +141,8 @@ const Page = () => {
|
|||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
limit: 36,
|
limit: 36,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0].key],
|
sortOrder: [sortOrder[0]],
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
recursive: false,
|
recursive: false,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
@@ -225,7 +233,8 @@ const Page = () => {
|
|||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} />
|
{/* <MoviePoster item={item} /> */}
|
||||||
|
<ItemPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</MemoizedTouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
@@ -338,13 +347,15 @@ const Page = () => {
|
|||||||
className="mr-1"
|
className="mr-1"
|
||||||
collectionId={libraryId}
|
collectionId={libraryId}
|
||||||
queryKey="sortBy"
|
queryKey="sortBy"
|
||||||
queryFn={async () => sortOptions}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title="Sort By"
|
title="Sort By"
|
||||||
renderItemLabel={(item) => item.value}
|
renderItemLabel={(item) =>
|
||||||
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.value.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -356,13 +367,15 @@ const Page = () => {
|
|||||||
className="mr-1"
|
className="mr-1"
|
||||||
collectionId={libraryId}
|
collectionId={libraryId}
|
||||||
queryKey="sortOrder"
|
queryKey="sortOrder"
|
||||||
queryFn={async () => sortOrderOptions}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
title="Sort Order"
|
title="Sort Order"
|
||||||
renderItemLabel={(item) => item.value}
|
renderItemLabel={(item) =>
|
||||||
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.value.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -417,6 +430,7 @@ const Page = () => {
|
|||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
extraData={orientation}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
estimatedItemSize={244}
|
||||||
numColumns={getNumberOfColumns()}
|
numColumns={getNumberOfColumns()}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { StyleSheet, View } from "react-native";
|
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 ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { StatusBar } from "expo-status-bar";
|
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 { useEffect, useRef } from "react";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||||
|
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
|
|
||||||
@@ -71,8 +73,24 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings]);
|
}, [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 url = Linking.useURL();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
const { hostname, path, queryParams } = Linking.parse(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 { useEffect, useMemo } from "react";
|
||||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source: MediaSourceInfo;
|
source: MediaSourceInfo;
|
||||||
@@ -22,6 +23,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
@@ -33,9 +36,22 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const defaultAudioIndex = audioStreams?.find(
|
||||||
|
(x) => x.Language === settings?.defaultAudioLanguage
|
||||||
|
)?.Index;
|
||||||
|
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
|
||||||
|
onChange(defaultAudioIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const index = source.DefaultAudioStreamIndex;
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Platform, View, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastContext,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useDevices,
|
useDevices,
|
||||||
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
const sessionManager = GoogleCast.getSessionManager();
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -38,31 +41,47 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
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"
|
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CastButton style={{ tintColor: "white", height, width }} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "android")
|
if (Platform.OS === "android")
|
||||||
return (
|
return (
|
||||||
<View
|
<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"
|
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CastButton style={{ tintColor: "white", height, width }} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlurView
|
<TouchableOpacity
|
||||||
intensity={100}
|
onPress={() => {
|
||||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
{...props}
|
{...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,
|
videoRef,
|
||||||
presentFullscreenPlayer,
|
presentFullscreenPlayer,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
onBuffer,
|
||||||
} = usePlayback();
|
} = usePlayback();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -182,18 +183,24 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
enable: true,
|
enable: true,
|
||||||
thread: true,
|
thread: true,
|
||||||
}}
|
}}
|
||||||
|
onIdle={() => {
|
||||||
|
console.log("IDLE");
|
||||||
|
}}
|
||||||
|
fullscreenAutorotate={true}
|
||||||
|
onReadyForDisplay={() => {
|
||||||
|
console.log("READY FOR DISPLAY");
|
||||||
|
}}
|
||||||
onProgress={(e) => onProgress(e)}
|
onProgress={(e) => onProgress(e)}
|
||||||
subtitleStyle={{
|
subtitleStyle={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
}}
|
}}
|
||||||
|
onBuffer={(e) => onBuffer(e.isBuffering)}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
presentFullscreenPlayer();
|
presentFullscreenPlayer();
|
||||||
}, 300);
|
}, 300);
|
||||||
}}
|
}}
|
||||||
onFullscreenPlayerDidDismiss={() => {}}
|
|
||||||
onFullscreenPlayerDidPresent={() => {}}
|
|
||||||
onPlaybackStateChanged={(e) => {
|
onPlaybackStateChanged={(e) => {
|
||||||
if (e.isPlaying === true) {
|
if (e.isPlaying === true) {
|
||||||
playVideo(false);
|
playVideo(false);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
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 { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
@@ -54,11 +54,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userCanDownload = useMemo(() => {
|
||||||
|
return user?.Policy?.EnableContentDownloading;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom sheet
|
* Bottom sheet
|
||||||
*/
|
*/
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["50%"], []);
|
|
||||||
|
|
||||||
const handlePresentModalPress = useCallback(() => {
|
const handlePresentModalPress = useCallback(() => {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
@@ -286,14 +289,21 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
<Button
|
<Button
|
||||||
className="mt-auto"
|
className="mt-auto"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
closeModal();
|
if (userCanDownload === true) {
|
||||||
queueActions.enqueue(queue, setQueue, {
|
closeModal();
|
||||||
id: item.Id!,
|
queueActions.enqueue(queue, setQueue, {
|
||||||
execute: async () => {
|
id: item.Id!,
|
||||||
await initiateDownload();
|
execute: async () => {
|
||||||
},
|
await initiateDownload();
|
||||||
item,
|
},
|
||||||
});
|
item,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
"Disabled",
|
||||||
|
"This user is not allowed to download files."
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
color="purple"
|
color="purple"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { ItemImage } from "@/components/common/ItemImage";
|
|||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useCastDevice } from "react-native-google-cast";
|
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 { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
|
||||||
import Animated, {
|
|
||||||
useSharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
withTiming,
|
|
||||||
runOnJS,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { set } from "lodash";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -61,7 +62,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loadingImage, setLoadingImage] = useState(true);
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
|
|
||||||
const [orientation, setOrientation] = useState(
|
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 {
|
const {
|
||||||
data: item,
|
data: item,
|
||||||
@@ -166,6 +166,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
}
|
}
|
||||||
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
||||||
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
||||||
|
else headerHeightRef.current = 400;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
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 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(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(
|
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
|
||||||
isLoading || isFetching || loadingImage || (logoUrl && loadingLogo)
|
}, [isLoading, isFetching, loadingLogo, logoUrl]);
|
||||||
);
|
|
||||||
}, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -262,6 +273,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||||
{localItem && (
|
{localItem && (
|
||||||
<ItemImage
|
<ItemImage
|
||||||
|
useThemeColor
|
||||||
variant={
|
variant={
|
||||||
localItem.Type === "Movie" && logoUrl
|
localItem.Type === "Movie" && logoUrl
|
||||||
? "Backdrop"
|
? "Backdrop"
|
||||||
@@ -272,8 +284,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
onLoad={() => setLoadingImage(false)}
|
|
||||||
onError={() => setLoadingImage(false)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</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 { PropsWithChildren, ReactNode } from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
title?: string | null | undefined;
|
title?: string | null | undefined;
|
||||||
subTitle?: string | null | undefined;
|
subTitle?: string | null | undefined;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -17,7 +22,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<TouchableOpacity
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -26,6 +31,6 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{iconAfter}
|
{iconAfter}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,115 +1,160 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
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 { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
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 { TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
interpolate,
|
||||||
|
interpolateColor,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
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> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 500;
|
||||||
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
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 memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||||
const progress = useSharedValue(0);
|
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||||
|
|
||||||
// Create shared values for start and end colors
|
const startWidth = useSharedValue(0);
|
||||||
const startColor = useSharedValue(color);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(color);
|
const endColor = useSharedValue(memoizedColor);
|
||||||
|
const startColor = useSharedValue(memoizedColor);
|
||||||
|
const widthProgress = useSharedValue(0);
|
||||||
|
const colorChangeProgress = useSharedValue(0);
|
||||||
|
|
||||||
useEffect(() => {
|
const directStream = useMemo(() => {
|
||||||
// When color changes, update end color and animate progress
|
return !url?.includes("m3u8");
|
||||||
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 onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!url || !item) return;
|
if (!url || !item) return;
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setCurrentlyPlayingState({ item, url });
|
setCurrentlyPlayingState({ item, url });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
const options = ["Chromecast", "Device", "Cancel"];
|
||||||
const cancelButtonIndex = 2;
|
const cancelButtonIndex = 2;
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
options,
|
options,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
},
|
},
|
||||||
async (selectedIndex: number | undefined) => {
|
async (selectedIndex: number | undefined) => {
|
||||||
|
if (!api) return;
|
||||||
|
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||||
|
const isOpeningCurrentlyPlayingMedia =
|
||||||
|
currentTitle && currentTitle === item?.Name;
|
||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
else {
|
else {
|
||||||
client.loadMedia({
|
// If we're opening a currently playing item, don't restart the media.
|
||||||
mediaInfo: {
|
// Instead just open controls.
|
||||||
contentUrl: url,
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
contentType: "video/mp4",
|
CastContext.showExpandedControls();
|
||||||
metadata: {
|
return;
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
}
|
||||||
title: item.Name || "",
|
client
|
||||||
subtitle: item.Overview || "",
|
.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;
|
break;
|
||||||
@@ -123,56 +168,154 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const playbackPercent = useMemo(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = memoizedItem.UserData;
|
||||||
if (!userData) return 0;
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
const PlaybackPositionTicks = userData.PlaybackPositionTicks;
|
return userData.PlaybackPositionTicks > 0
|
||||||
if (!PlaybackPositionTicks) return 0;
|
? Math.max(
|
||||||
return (PlaybackPositionTicks / item.RunTimeTicks) * 100;
|
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
|
||||||
}, [item]);
|
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 (
|
return (
|
||||||
<TouchableOpacity onPress={onPress} className="relative" {...props}>
|
<View>
|
||||||
<Animated.View
|
<TouchableOpacity
|
||||||
style={[
|
accessibilityLabel="Play button"
|
||||||
animatedPrimaryStyle,
|
accessibilityHint="Tap to play the media"
|
||||||
{
|
onPress={onPress}
|
||||||
width:
|
className="relative"
|
||||||
playbackPercent === 0
|
{...props}
|
||||||
? "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 className="flex flex-row items-center space-x-2">
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.View
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
style={[
|
||||||
</Animated.Text>
|
animatedPrimaryStyle,
|
||||||
<Animated.Text style={animatedTextStyle}>
|
animatedWidthStyle,
|
||||||
<Ionicons name="play-circle" size={24} />
|
{
|
||||||
</Animated.Text>
|
height: "100%",
|
||||||
{client && (
|
},
|
||||||
<Animated.Text style={animatedTextStyle}>
|
]}
|
||||||
<Feather name="cast" size={22} />
|
/>
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source: MediaSourceInfo;
|
source: MediaSourceInfo;
|
||||||
@@ -22,6 +23,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||||
[source]
|
[source]
|
||||||
@@ -33,13 +36,21 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = source.DefaultSubtitleStreamIndex;
|
// const index = source.DefaultAudioStreamIndex;
|
||||||
if (index !== undefined && index !== null) {
|
// if (index !== undefined && index !== null) {
|
||||||
onChange(index);
|
// onChange(index);
|
||||||
} else {
|
// return;
|
||||||
onChange(-1);
|
// }
|
||||||
|
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;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +1,83 @@
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image, ImageProps, ImageSource } from "expo-image";
|
import { Image, ImageProps, ImageSource } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
interface Props extends ImageProps {
|
interface Props extends ImageProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
variant?: "Backdrop" | "Primary" | "Thumb" | "Logo";
|
variant?:
|
||||||
|
| "Primary"
|
||||||
|
| "Backdrop"
|
||||||
|
| "ParentBackdrop"
|
||||||
|
| "ParentLogo"
|
||||||
|
| "Logo"
|
||||||
|
| "AlbumPrimary"
|
||||||
|
| "SeriesPrimary"
|
||||||
|
| "Screenshot"
|
||||||
|
| "Thumb";
|
||||||
quality?: number;
|
quality?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
useThemeColor?: boolean;
|
||||||
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemImage: React.FC<Props> = ({
|
export const ItemImage: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
variant,
|
variant = "Primary",
|
||||||
quality = 90,
|
quality = 90,
|
||||||
width = 1000,
|
width = 1000,
|
||||||
|
useThemeColor = false,
|
||||||
|
onError,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const source = useMemo(() => {
|
const source = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) {
|
||||||
|
onError && onError();
|
||||||
let tag: string | null | undefined;
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant,
|
||||||
|
quality,
|
||||||
|
width,
|
||||||
|
});
|
||||||
|
}, [api, item, quality, variant, width]);
|
||||||
|
|
||||||
return src;
|
// return placeholder icon if no source
|
||||||
}, [item.ImageTags]);
|
if (!source?.uri)
|
||||||
|
return (
|
||||||
useImageColors(source?.uri);
|
<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 (
|
return (
|
||||||
<Image
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
transition={300}
|
transition={300}
|
||||||
placeholder={{
|
placeholder={{
|
||||||
blurhash: source?.blurhash,
|
blurhash: source?.blurhash,
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: source?.uri,
|
uri: source?.uri,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { Alert, TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -55,7 +55,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "UserView") {
|
if (item.Type === "UserView") {
|
||||||
Alert.alert("Not implemented");
|
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ interface EpisodeCardProps {
|
|||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useFiles();
|
const { deleteFile } = useFiles();
|
||||||
|
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
const { startDownloadedFilePlayback } = usePlayback();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(async () => {
|
const handleOpenFile = useCallback(async () => {
|
||||||
setCurrentlyPlayingState({
|
startDownloadedFilePlayback({
|
||||||
item,
|
item,
|
||||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||||
});
|
});
|
||||||
}, [item, setCurrentlyPlayingState]);
|
}, [item, startDownloadedFilePlayback]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const { deleteFile } = useFiles();
|
const { deleteFile } = useFiles();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
const { startDownloadedFilePlayback } = usePlayback();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
setCurrentlyPlayingState({
|
startDownloadedFilePlayback({
|
||||||
item,
|
item,
|
||||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||||
});
|
});
|
||||||
}, [item, setCurrentlyPlayingState]);
|
}, [item, startDownloadedFilePlayback]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const FilterButton = <T,>({
|
|||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
set,
|
set,
|
||||||
values,
|
values, // selected values
|
||||||
title,
|
title,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export const FilterSheet = <T,>({
|
|||||||
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
<Text>{renderItemLabel(item)}</Text>
|
<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-on" size={24} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="radio-button-off" size={24} color="white" />
|
<Ionicons name="radio-button-off" size={24} color="white" />
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
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 { View, ViewProps } from "react-native";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import {
|
|
||||||
type QueryKey,
|
|
||||||
useQuery,
|
|
||||||
type QueryFunction,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import SeriesPoster from "../posters/SeriesPoster";
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
import { EpisodePoster } from "../posters/EpisodePoster";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -32,6 +32,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [settings] = useSettings();
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { useAtom } from "jotai";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
|
||||||
CollectionType,
|
CollectionType,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} 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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { sortBy } from "lodash";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { Image } from "expo-image";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 {
|
interface Props extends TouchableOpacityProps {
|
||||||
library: BaseItemDto;
|
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 { 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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -10,6 +14,7 @@ import { Loader } from "../Loader";
|
|||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
|
import { MediaToggles } from "./MediaToggles";
|
||||||
|
|
||||||
export const SettingToggles: React.FC = () => {
|
export const SettingToggles: React.FC = () => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
@@ -44,314 +49,302 @@ export const SettingToggles: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
{/* <View>
|
||||||
<View className="shrink">
|
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
||||||
<Text className="font-semibold">Auto rotate</Text>
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
||||||
<Text className="text-xs opacity-50">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
Important on android since the video player orientation is locked to
|
<View className="shrink">
|
||||||
the app orientation.
|
<Text className="font-semibold">Coming soon</Text>
|
||||||
</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>
|
</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> */}
|
||||||
<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">
|
<MediaToggles />
|
||||||
<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>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<TouchableOpacity
|
<View className="shrink">
|
||||||
onPress={() => {
|
<Text className="font-semibold">Auto rotate</Text>
|
||||||
Linking.openURL(
|
<Text className="text-xs opacity-50">
|
||||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
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>
|
</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({
|
<View className="flex flex-col">
|
||||||
mediaListCollectionIds:
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
<View className="flex flex-col">
|
||||||
? settings?.mediaListCollectionIds.filter(
|
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||||
(id) => id !== mlc.Id
|
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||||
)
|
<TouchableOpacity
|
||||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
onPress={() => {
|
||||||
});
|
Linking.openURL(
|
||||||
|
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
))}
|
<Switch
|
||||||
{isLoadingMediaListCollections && (
|
value={settings?.usePopularPlugin}
|
||||||
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
onValueChange={(value) =>
|
||||||
<Loader />
|
updateSettings({ usePopularPlugin: value })
|
||||||
</View>
|
}
|
||||||
)}
|
/>
|
||||||
{mediaListCollections?.length === 0 && (
|
</View>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
{settings?.usePopularPlugin && (
|
||||||
<Text className="text-xs opacity-50">
|
<View className="flex flex-col py-2 bg-neutral-900">
|
||||||
No collections found. Add some in Jellyfin.
|
{mediaListCollections?.map((mlc) => (
|
||||||
</Text>
|
<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>
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Force direct play</Text>
|
<Text className="font-semibold">Force direct play</Text>
|
||||||
<Text className="text-xs opacity-50 shrink">
|
<Text className="text-xs opacity-50 shrink">
|
||||||
This will always request direct play. This is good if you want to
|
This will always request direct play. This is good if you want
|
||||||
try to stream movies you think the device supports.
|
to try to stream movies you think the device supports.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings?.forceDirectPlay}
|
value={settings?.forceDirectPlay}
|
||||||
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
|
onValueChange={(value) =>
|
||||||
/>
|
updateSettings({ forceDirectPlay: value })
|
||||||
</View>
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<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>
|
<View className="flex flex-col shrink">
|
||||||
<DropdownMenu.Item
|
<Text className="font-semibold">Device profile</Text>
|
||||||
key="1"
|
<Text className="text-xs opacity-50">
|
||||||
onSelect={() => {
|
A profile used for deciding what audio and video codecs the
|
||||||
updateSettings({ deviceProfile: "Expo" });
|
device supports.
|
||||||
}}
|
</Text>
|
||||||
>
|
</View>
|
||||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
<DropdownMenu.Root>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Item
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
key="2"
|
<Text>{settings?.deviceProfile}</Text>
|
||||||
onSelect={() => {
|
</TouchableOpacity>
|
||||||
updateSettings({ deviceProfile: "Native" });
|
</DropdownMenu.Trigger>
|
||||||
}}
|
<DropdownMenu.Content
|
||||||
>
|
loop={true}
|
||||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
side="bottom"
|
||||||
</DropdownMenu.Item>
|
align="start"
|
||||||
<DropdownMenu.Item
|
alignOffset={0}
|
||||||
key="3"
|
avoidCollisions={true}
|
||||||
onSelect={() => {
|
collisionPadding={8}
|
||||||
updateSettings({ deviceProfile: "Old" });
|
sideOffset={8}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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.Label>Profiles</DropdownMenu.Label>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.Item
|
||||||
<DropdownMenu.Item
|
key="1"
|
||||||
key="2"
|
onSelect={() => {
|
||||||
onSelect={() => {
|
updateSettings({ deviceProfile: "Expo" });
|
||||||
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
|
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
||||||
</Button>
|
</DropdownMenu.Item>
|
||||||
</View>
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
<Text className="text-neutral-500 mt-2">
|
onSelect={() => {
|
||||||
{settings?.marlinServerUrl}
|
updateSettings({ deviceProfile: "Native" });
|
||||||
</Text>
|
}}
|
||||||
</>
|
>
|
||||||
|
<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>
|
||||||
)}
|
<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>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.10.2",
|
"channel": "0.12.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.10.2",
|
"channel": "0.12.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"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 { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useAtom } from "jotai";
|
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);
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (disabled) return;
|
||||||
if (uri) {
|
if (uri) {
|
||||||
getColors(uri, {
|
getColors(uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
@@ -38,5 +42,5 @@ export const useImageColors = (uri: string | undefined | null) => {
|
|||||||
console.error("Error getting colors", error);
|
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-native-menu/menu": "^1.1.2",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
"@shopify/flash-list": "1.6.4",
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.51.16",
|
"@tanstack/react-query": "^5.54.1",
|
||||||
"@types/lodash": "^4.17.7",
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.7.3",
|
"axios": "^1.7.7",
|
||||||
"expo": "~51.0.31",
|
"expo": "~51.0.32",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-dev-client": "~4.0.25",
|
"expo-dev-client": "~4.0.26",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.9",
|
"expo-font": "~12.0.10",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
"expo-image": "~1.12.15",
|
"expo-image": "~1.12.15",
|
||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linear-gradient": "~13.0.2",
|
"expo-linear-gradient": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
"expo-navigation-bar": "~3.0.7",
|
"expo-navigation-bar": "~3.0.7",
|
||||||
|
"expo-network": "~6.0.1",
|
||||||
"expo-router": "~3.5.23",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
"expo-screen-orientation": "~7.0.5",
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-sensors": "~13.0.9",
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
"expo-updates": "~0.25.24",
|
"expo-updates": "~0.25.24",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-video": "^6.4.5",
|
"react-native-video": "^6.5.0",
|
||||||
"react-native-web": "~0.19.10",
|
"react-native-web": "~0.19.10",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.3",
|
"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 { useInterval } from "@/hooks/useInterval";
|
||||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import axios, { AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
@@ -62,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.10.2" },
|
clientInfo: { name: "Streamyfin", version: "0.12.0" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
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 [isPolling, setIsPolling] = useState<boolean>(false);
|
||||||
const [secret, setSecret] = useState<string | null>(null);
|
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(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.10.2"`,
|
}, DeviceId="${deviceId}", Version="0.12.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,21 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
PlaybackInfoResponse,
|
PlaybackInfoResponse,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi, getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce, isBuffer } from "lodash";
|
||||||
import { Alert, Platform } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { OnProgressData, type VideoRef } from "react-native-video";
|
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
|
import {
|
||||||
|
GroupData,
|
||||||
|
GroupJoinedData,
|
||||||
|
PlayQueueData,
|
||||||
|
StateUpdateData,
|
||||||
|
} from "@/types/syncplay";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
type CurrentlyPlayingState = {
|
type CurrentlyPlayingState = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -35,6 +43,8 @@ interface PlaybackContextType {
|
|||||||
sessionData: PlaybackInfoResponse | null | undefined;
|
sessionData: PlaybackInfoResponse | null | undefined;
|
||||||
currentlyPlaying: CurrentlyPlayingState | null;
|
currentlyPlaying: CurrentlyPlayingState | null;
|
||||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||||
|
onBuffer: (isBuffering: boolean) => void;
|
||||||
|
onReady: () => void;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
progressTicks: number | null;
|
progressTicks: number | null;
|
||||||
@@ -50,6 +60,9 @@ interface PlaybackContextType {
|
|||||||
setCurrentlyPlayingState: (
|
setCurrentlyPlayingState: (
|
||||||
currentlyPlaying: CurrentlyPlayingState | null
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
) => void;
|
) => void;
|
||||||
|
startDownloadedFilePlayback: (
|
||||||
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
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 [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||||
const [volume, _setVolume] = useState<number | null>(null);
|
const [volume, _setVolume] = useState<number | null>(null);
|
||||||
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
||||||
|
const [syncplayGroup, setSyncplayGroup] = useState<GroupData | null>(null);
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] =
|
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||||
useState<CurrentlyPlayingState | null>(null);
|
useState<CurrentlyPlayingState | null>(null);
|
||||||
|
|
||||||
@@ -92,41 +106,90 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
queryFn: getDeviceId,
|
queryFn: getDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setCurrentlyPlayingState = useCallback(
|
const startDownloadedFilePlayback = useCallback(
|
||||||
async (state: CurrentlyPlayingState | null) => {
|
async (state: CurrentlyPlayingState | null) => {
|
||||||
if (!api) return;
|
if (!state) {
|
||||||
|
|
||||||
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 {
|
|
||||||
setCurrentlyPlaying(null);
|
setCurrentlyPlaying(null);
|
||||||
setIsFullscreen(false);
|
|
||||||
setIsPlaying(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]
|
[settings, user, api]
|
||||||
@@ -204,6 +267,53 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
|
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onBuffer = useCallback(
|
||||||
|
(isBuffering: boolean) => {
|
||||||
|
console.log("Buffering...", "Playing:", isPlaying);
|
||||||
|
if (
|
||||||
|
isBuffering &&
|
||||||
|
syncplayGroup?.GroupId &&
|
||||||
|
isPlaying === false &&
|
||||||
|
currentlyPlaying?.item.PlaylistItemId
|
||||||
|
) {
|
||||||
|
console.log("Sending syncplay buffering...");
|
||||||
|
getSyncPlayApi(api!).syncPlayBuffering({
|
||||||
|
bufferRequestDto: {
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
When: new Date().toISOString(),
|
||||||
|
PositionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isPlaying,
|
||||||
|
syncplayGroup?.GroupId,
|
||||||
|
currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
api,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onReady = useCallback(() => {
|
||||||
|
if (syncplayGroup?.GroupId && currentlyPlaying?.item.PlaylistItemId) {
|
||||||
|
getSyncPlayApi(api!).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: new Date().toISOString(),
|
||||||
|
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PositionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
syncplayGroup?.GroupId,
|
||||||
|
currentlyPlaying?.item.PlaylistItemId,
|
||||||
|
progressTicks,
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
]);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
debounce((e: OnProgressData) => {
|
debounce((e: OnProgressData) => {
|
||||||
_onProgress(e);
|
_onProgress(e);
|
||||||
@@ -221,61 +331,185 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setIsFullscreen(false);
|
setIsFullscreen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const seek = useCallback((ticks: number) => {
|
||||||
if (!deviceId || !api?.accessToken) return;
|
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("https://", "")
|
||||||
.replace("http://", "")}/socket?api_key=${
|
.replace("http://", "")}/socket?api_key=${
|
||||||
api?.accessToken
|
api?.accessToken
|
||||||
}&deviceId=${deviceId}`;
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
const newWebSocket = new WebSocket(url);
|
let ws: WebSocket | null = null;
|
||||||
|
|
||||||
let keepAliveInterval: NodeJS.Timeout | null = null;
|
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
newWebSocket.onopen = () => {
|
const connect = () => {
|
||||||
setIsConnected(true);
|
ws = new WebSocket(url);
|
||||||
// Start sending "KeepAlive" message every 30 seconds
|
|
||||||
keepAliveInterval = setInterval(() => {
|
ws.onopen = () => {
|
||||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
setIsConnected(true);
|
||||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
keepAliveInterval = setInterval(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("⬆︎ KeepAlive...");
|
||||||
|
ws.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error("WebSocket error:", e);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
}, 30000);
|
setTimeout(connect, 5000); // Attempt to reconnect after 5 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
setWs(ws);
|
||||||
};
|
};
|
||||||
|
|
||||||
newWebSocket.onerror = (e) => {
|
connect();
|
||||||
console.error("WebSocket error:", e);
|
|
||||||
setIsConnected(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
newWebSocket.onclose = (e) => {
|
|
||||||
if (keepAliveInterval) {
|
|
||||||
clearInterval(keepAliveInterval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setWs(newWebSocket);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
if (keepAliveInterval) {
|
if (keepAliveInterval) {
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
newWebSocket.close();
|
|
||||||
};
|
};
|
||||||
}, [api, deviceId, user]);
|
}, [api?.accessToken, deviceId, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (!ws || !api) return;
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
const command = json?.Data?.Command;
|
const command = json?.Data?.Command;
|
||||||
|
|
||||||
console.log("[WS] ~ ", json);
|
if (json.MessageType === "KeepAlive") {
|
||||||
|
console.log("⬇︎ KeepAlive...");
|
||||||
|
} else if (json.MessageType === "ForceKeepAlive") {
|
||||||
|
console.log("⬇︎ ForceKeepAlive...");
|
||||||
|
} else if (json.MessageType === "SyncPlayCommand") {
|
||||||
|
console.log("SyncPlayCommand ~", command, json.Data);
|
||||||
|
switch (command) {
|
||||||
|
case "Stop":
|
||||||
|
console.log("STOP");
|
||||||
|
stopPlayback();
|
||||||
|
break;
|
||||||
|
case "Pause":
|
||||||
|
console.log("PAUSE");
|
||||||
|
pauseVideo();
|
||||||
|
break;
|
||||||
|
case "Play":
|
||||||
|
case "Unpause":
|
||||||
|
console.log("PLAY");
|
||||||
|
playVideo();
|
||||||
|
break;
|
||||||
|
case "Seek":
|
||||||
|
console.log("SEEK", json.Data.PositionTicks);
|
||||||
|
seek(json.Data.PositionTicks);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (json.MessageType === "SyncPlayGroupUpdate") {
|
||||||
|
const type = json.Data.Type;
|
||||||
|
|
||||||
|
if (type === "StateUpdate") {
|
||||||
|
const data = json.Data.Data as StateUpdateData;
|
||||||
|
console.log("StateUpdate ~", data);
|
||||||
|
} else if (type === "GroupJoined") {
|
||||||
|
const data = json.Data.Data as GroupData;
|
||||||
|
setSyncplayGroup(data);
|
||||||
|
console.log("GroupJoined ~", data);
|
||||||
|
} else if (type === "GroupLeft") {
|
||||||
|
console.log("GroupLeft");
|
||||||
|
setSyncplayGroup(null);
|
||||||
|
} else if (type === "PlayQueue") {
|
||||||
|
const data = json.Data.Data as PlayQueueData;
|
||||||
|
console.log("PlayQueue ~", {
|
||||||
|
IsPlaying: data.IsPlaying,
|
||||||
|
Reason: data.Reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.Reason === "SetCurrentItem") {
|
||||||
|
console.log("SetCurrentItem ~ ", json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Reason === "NewPlaylist") {
|
||||||
|
const itemId = data.Playlist?.[data.PlayingItemIndex].ItemId;
|
||||||
|
if (!itemId) {
|
||||||
|
console.error("No itemId found in PlayQueue");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set playback item
|
||||||
|
getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId,
|
||||||
|
}).then(async (item) => {
|
||||||
|
if (!item) {
|
||||||
|
Alert.alert("Error", "Could not find item for syncplay");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: data.StartPositionTicks,
|
||||||
|
userId: user?.Id,
|
||||||
|
mediaSourceId: item?.MediaSources?.[0].Id!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
Alert.alert("Error", "Could not find stream url for syncplay");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setCurrentlyPlayingState(
|
||||||
|
{
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
!data.IsPlaying
|
||||||
|
);
|
||||||
|
|
||||||
|
await getSyncPlayApi(api).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
IsPlaying: data.IsPlaying,
|
||||||
|
PositionTicks: data.StartPositionTicks,
|
||||||
|
PlaylistItemId: data.Playlist[0].PlaylistItemId,
|
||||||
|
When: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[WS] ~ ", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log("[WS] ~ ", json);
|
||||||
|
}
|
||||||
|
|
||||||
// On PlayPause
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
|
// On PlayPause
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
if (isPlaying) pauseVideo();
|
if (isPlaying) pauseVideo();
|
||||||
else playVideo();
|
else playVideo();
|
||||||
@@ -297,16 +531,18 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
Alert.alert(title, body);
|
Alert.alert(title, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
}, [ws, stopPlayback, playVideo, pauseVideo, setVolume, api, seek]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaybackContext.Provider
|
<PlaybackContext.Provider
|
||||||
value={{
|
value={{
|
||||||
onProgress,
|
onProgress,
|
||||||
|
onReady,
|
||||||
progressTicks,
|
progressTicks,
|
||||||
setVolume,
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setIsFullscreen,
|
setIsFullscreen,
|
||||||
|
onBuffer,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentlyPlaying,
|
currentlyPlaying,
|
||||||
@@ -318,6 +554,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
stopPlayback,
|
stopPlayback,
|
||||||
presentFullscreenPlayer,
|
presentFullscreenPlayer,
|
||||||
dismissFullscreenPlayer,
|
dismissFullscreenPlayer,
|
||||||
|
startDownloadedFilePlayback,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
47
types/syncplay.ts
Normal file
47
types/syncplay.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export type PlaylistItem = {
|
||||||
|
ItemId: string;
|
||||||
|
PlaylistItemId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayQueueData = {
|
||||||
|
IsPlaying: boolean;
|
||||||
|
LastUpdate: string;
|
||||||
|
PlayingItemIndex: number;
|
||||||
|
Playlist: PlaylistItem[];
|
||||||
|
Reason: "NewPlaylist" | "SetCurrentItem"; // or use string if more values are expected
|
||||||
|
RepeatMode: "RepeatNone"; // or use string if more values are expected
|
||||||
|
ShuffleMode: "Sorted"; // or use string if more values are expected
|
||||||
|
StartPositionTicks: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupData = {
|
||||||
|
GroupId: string;
|
||||||
|
GroupName: string;
|
||||||
|
LastUpdatedAt: string;
|
||||||
|
Participants: Participant[];
|
||||||
|
State: string; // You can use an enum or union type if there are known possible states
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncPlayCommandData = {
|
||||||
|
Command: string;
|
||||||
|
EmittedAt: string;
|
||||||
|
GroupId: string;
|
||||||
|
PlaylistItemId: string;
|
||||||
|
PositionTicks: number;
|
||||||
|
When: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StateUpdateData = {
|
||||||
|
State: "Waiting" | "Playing" | "Paused";
|
||||||
|
Reason: "Pause" | "Unpause";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupJoinedData = {
|
||||||
|
GroupId: string;
|
||||||
|
GroupName: string;
|
||||||
|
LastUpdatedAt: string;
|
||||||
|
Participants: string[];
|
||||||
|
State: "Idle";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Participant = string[];
|
||||||
@@ -1,50 +1,133 @@
|
|||||||
import {
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
ItemFilter,
|
import { atom } from "jotai";
|
||||||
ItemSortBy,
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||||
NameGuidPair,
|
|
||||||
SortOrder,
|
export enum SortByOption {
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
Default = "Default",
|
||||||
import { atom, useAtom } from "jotai";
|
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: {
|
export const sortOptions: {
|
||||||
key: ItemSortBy;
|
key: SortByOption;
|
||||||
value: string;
|
value: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "SortName", value: "Name" },
|
{ key: SortByOption.Default, value: "Default" },
|
||||||
{ key: "CommunityRating", value: "Community Rating" },
|
{ key: SortByOption.SortName, value: "Name" },
|
||||||
{ key: "CriticRating", value: "Critics Rating" },
|
{ key: SortByOption.CommunityRating, value: "Community Rating" },
|
||||||
{ key: "DateCreated", value: "Date Added" },
|
{ key: SortByOption.CriticRating, value: "Critics Rating" },
|
||||||
// Only works for shows (last episode added) keeping for future ref.
|
{ key: SortByOption.DateCreated, value: "Date Added" },
|
||||||
// { key: "DateLastContentAdded", value: "Content Added" },
|
{ key: SortByOption.DatePlayed, value: "Date Played" },
|
||||||
{ key: "DatePlayed", value: "Date Played" },
|
{ key: SortByOption.PlayCount, value: "Play Count" },
|
||||||
{ key: "PlayCount", value: "Play Count" },
|
{ key: SortByOption.ProductionYear, value: "Production Year" },
|
||||||
{ key: "ProductionYear", value: "Production Year" },
|
{ key: SortByOption.Runtime, value: "Runtime" },
|
||||||
{ key: "Runtime", value: "Runtime" },
|
{ key: SortByOption.OfficialRating, value: "Official Rating" },
|
||||||
{ key: "OfficialRating", value: "Official Rating" },
|
{ key: SortByOption.PremiereDate, value: "Premiere Date" },
|
||||||
{ key: "PremiereDate", value: "Premiere Date" },
|
{ key: SortByOption.StartDate, value: "Start Date" },
|
||||||
{ key: "StartDate", value: "Start Date" },
|
{ key: SortByOption.IsUnplayed, value: "Is Unplayed" },
|
||||||
{ key: "IsUnplayed", value: "Is Unplayed" },
|
{ key: SortByOption.IsPlayed, value: "Is Played" },
|
||||||
{ key: "IsPlayed", value: "Is Played" },
|
{ key: SortByOption.AirTime, value: "Air Time" },
|
||||||
// Broken in JF
|
{ key: SortByOption.Studio, value: "Studio" },
|
||||||
// { key: "VideoBitRate", value: "Video Bit Rate" },
|
{ key: SortByOption.IsFavoriteOrLiked, value: "Is Favorite Or Liked" },
|
||||||
{ key: "AirTime", value: "Air Time" },
|
{ key: SortByOption.Random, value: "Random" },
|
||||||
{ key: "Studio", value: "Studio" },
|
|
||||||
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
|
|
||||||
{ key: "Random", value: "Random" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const sortOrderOptions: {
|
export const sortOrderOptions: {
|
||||||
key: SortOrder;
|
key: SortOrderOption;
|
||||||
value: string;
|
value: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "Ascending", value: "Ascending" },
|
{ key: SortOrderOption.Ascending, value: "Ascending" },
|
||||||
{ key: "Descending", value: "Descending" },
|
{ key: SortOrderOption.Descending, value: "Descending" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const genreFilterAtom = atom<string[]>([]);
|
export const genreFilterAtom = atom<string[]>([]);
|
||||||
export const tagsFilterAtom = atom<string[]>([]);
|
export const tagsFilterAtom = atom<string[]>([]);
|
||||||
export const yearFilterAtom = atom<string[]>([]);
|
export const yearFilterAtom = atom<string[]>([]);
|
||||||
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
|
export const sortByAtom = atom<SortByOption[]>([SortByOption.Default]);
|
||||||
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
|
export const sortOrderAtom = atom<SortOrderOption[]>([
|
||||||
sortOrderOptions[0],
|
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;
|
showStats: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DefaultLanguageOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Settings = {
|
type Settings = {
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
forceLandscapeInVideoPlayer?: boolean;
|
forceLandscapeInVideoPlayer?: boolean;
|
||||||
@@ -45,6 +50,9 @@ type Settings = {
|
|||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
|
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||||
|
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||||
|
showHomeTitles: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +83,9 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
showTitles: true,
|
showTitles: true,
|
||||||
showStats: true,
|
showStats: true,
|
||||||
},
|
},
|
||||||
|
defaultAudioLanguage: null,
|
||||||
|
defaultSubtitleLanguage: null,
|
||||||
|
showHomeTitles: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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;
|
userId: string | null | undefined;
|
||||||
startTimeTicks: number;
|
startTimeTicks: number;
|
||||||
maxStreamingBitrate?: number;
|
maxStreamingBitrate?: number;
|
||||||
sessionData: PlaybackInfoResponse;
|
sessionData?: PlaybackInfoResponse;
|
||||||
deviceProfile: any;
|
deviceProfile?: any;
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
@@ -72,16 +72,12 @@ export const getStreamUrl = async ({
|
|||||||
throw new Error("No media source");
|
throw new Error("No media source");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionData.PlaySessionId) {
|
|
||||||
throw new Error("no PlaySessionId");
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: string | null | undefined;
|
let url: string | null | undefined;
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
console.log("Using direct stream for video!");
|
console.log("Using direct stream for video!");
|
||||||
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
console.log("Using direct stream for audio!");
|
console.log("Using direct stream for audio!");
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
@@ -94,7 +90,9 @@ export const getStreamUrl = async ({
|
|||||||
TranscodingProtocol: "hls",
|
TranscodingProtocol: "hls",
|
||||||
AudioCodec: "aac",
|
AudioCodec: "aac",
|
||||||
api_key: api.accessToken,
|
api_key: api.accessToken,
|
||||||
PlaySessionId: sessionData.PlaySessionId,
|
PlaySessionId: sessionData?.PlaySessionId
|
||||||
|
? sessionData.PlaySessionId
|
||||||
|
: "",
|
||||||
StartTimeTicks: "0",
|
StartTimeTicks: "0",
|
||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
interface PlaybackStoppedParams {
|
interface PlaybackStoppedParams {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
@@ -27,17 +28,23 @@ export const reportPlaybackStopped = async ({
|
|||||||
if (!positionTicks || positionTicks === 0) return;
|
if (!positionTicks || positionTicks === 0) return;
|
||||||
|
|
||||||
if (!api) {
|
if (!api) {
|
||||||
console.error("Missing api");
|
writeToLog("WARN", "Could not report playback stopped due to missing api");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
console.error("Missing sessionId", sessionId);
|
writeToLog(
|
||||||
|
"WARN",
|
||||||
|
"Could not report playback stopped due to missing session id"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
console.error("Missing itemId");
|
writeToLog(
|
||||||
|
"WARN",
|
||||||
|
"Could not report playback progress due to missing item id"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user