mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 08:44:41 +01:00
Compare commits
97 Commits
v0.10.2
...
feat/new-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7d8721b3 | ||
|
|
f45139ff90 | ||
|
|
65579c88e5 | ||
|
|
d716e42c20 | ||
|
|
ffe1003710 | ||
|
|
5c008f64b5 | ||
|
|
721cd093f4 | ||
|
|
402bdec5ab | ||
|
|
595120229f | ||
|
|
09363bffdc | ||
|
|
c3237571a8 | ||
|
|
e3c4a291f0 | ||
|
|
ce2e5e0fb8 | ||
|
|
c7703df3ce | ||
|
|
b7629f6f2b | ||
|
|
409e2de6c8 | ||
|
|
7cb67d73ec | ||
|
|
1fe1438ecf | ||
|
|
611f5ae37b | ||
|
|
d2701254b3 | ||
|
|
994dd44fc5 | ||
|
|
f7e04dfa2d | ||
|
|
cd126bb1c7 | ||
|
|
ddbfb91260 | ||
|
|
caac40c4b1 | ||
|
|
2632feb3e8 | ||
|
|
778447c1fd | ||
|
|
5a1f555703 | ||
|
|
2ed18d6588 | ||
|
|
c494b8e9f9 | ||
|
|
354fdd6791 | ||
|
|
f48e0348ad | ||
|
|
23eaddf87c | ||
|
|
a90dfb2805 | ||
|
|
78d168050a | ||
|
|
b92d55b9a0 | ||
|
|
907f6193b5 | ||
|
|
6f34f2e6a6 | ||
|
|
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 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ''
|
||||||
labels: ''
|
labels: '❌ bug'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: ''
|
title: ''
|
||||||
labels: ''
|
labels: '✨ enhancement'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
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.14.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": 40,
|
||||||
"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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ export default function IndexLayout() {
|
|||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
<Stack.Screen
|
||||||
|
name="collections/[collectionId]"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import { SettingToggles } from "@/components/settings/SettingToggles";
|
|||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, readFromLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import { red } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
@@ -26,6 +30,36 @@ export default function settings() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
|
Alert.prompt(
|
||||||
|
"Quick connect",
|
||||||
|
"Enter the quick connect code",
|
||||||
|
async (text) => {
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
|
code: text,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
console.log(res.status, res.statusText, res.data);
|
||||||
|
if (res.status === 200) {
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
Alert.alert("Success", "Quick connect authorized");
|
||||||
|
} else {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -35,62 +69,89 @@ 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 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>
|
||||||
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
|
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||||
|
Authorize
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View>
|
||||||
<Button color="black" onPress={logout}>
|
<Text className="font-bold text-lg mb-2">Tests</Text>
|
||||||
Log out
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
onPress={() => {
|
||||||
onPress={async () => {
|
toast.success("Download started", {
|
||||||
await deleteAllFiles();
|
invert: true,
|
||||||
Haptics.notificationAsync(
|
});
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
|
color="black"
|
||||||
>
|
>
|
||||||
Delete all downloaded files
|
Test toast
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await clearLogs();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="font-bold text-2xl">Logs</Text>
|
<View>
|
||||||
<View className="flex flex-col space-y-2">
|
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
||||||
{logs?.map((log, index) => (
|
<View className="flex flex-col space-y-2">
|
||||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
<Button color="black" onPress={logout}>
|
||||||
<Text
|
Log out
|
||||||
className={`
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={async () => {
|
||||||
|
await deleteAllFiles();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete all downloaded files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={async () => {
|
||||||
|
await clearLogs();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete all logs
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{logs?.map((log, index) => (
|
||||||
|
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
mb-1
|
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>
|
||||||
|
|||||||
@@ -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,13 +1,16 @@
|
|||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
|
||||||
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
return (
|
||||||
|
<>
|
||||||
return memoizedContent;
|
<Stack.Screen options={{ autoHideHomeIndicator: true }} />
|
||||||
|
<ItemContent id={id} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(Page);
|
export default Page;
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import {
|
||||||
|
useFocusEffect,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
} from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
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 +43,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 +53,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 +116,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],
|
||||||
@@ -120,6 +132,13 @@ const Page = () => {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: library?.Name || "",
|
||||||
|
});
|
||||||
|
}, [library]);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -133,8 +152,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 +244,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 +358,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 +378,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 +441,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()}
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ export default function IndexLayout() {
|
|||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
<Stack.Screen
|
||||||
|
name="collections/[collectionId]"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export default function SearchLayout() {
|
|||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
<Stack.Screen
|
||||||
|
name="collections/[collectionId]"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,9 +278,9 @@ export default function search() {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/series/${item.Id}`)}
|
item={item}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
@@ -290,7 +290,7 @@ export default function search() {
|
|||||||
<Text className="opacity-50 text-xs">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -302,14 +302,14 @@ export default function search() {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
|
||||||
className="flex flex-col w-44"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -321,16 +321,16 @@ export default function search() {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
onPress={() => router.push(`/collections/${item.Id}`)}
|
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||||
@@ -13,11 +13,13 @@ 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";
|
||||||
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
@@ -45,6 +47,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 +74,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);
|
||||||
@@ -88,7 +107,12 @@ function Layout() {
|
|||||||
<PlaybackProvider>
|
<PlaybackProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName="/home">
|
<Stack
|
||||||
|
initialRouteName="/home"
|
||||||
|
screenOptions={{
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/(tabs)"
|
name="(auth)/(tabs)"
|
||||||
options={{
|
options={{
|
||||||
@@ -102,7 +126,8 @@ function Layout() {
|
|||||||
/>
|
/>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<CurrentlyPlayingBar />
|
<FullScreenVideoPlayer />
|
||||||
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PlaybackProvider>
|
</PlaybackProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
|
|||||||
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,21 @@ 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) {
|
||||||
}, []);
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import Video from "react-native-video";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
|
|
||||||
export const CurrentlyPlayingBar: React.FC = () => {
|
|
||||||
const segments = useSegments();
|
|
||||||
const {
|
|
||||||
currentlyPlaying,
|
|
||||||
pauseVideo,
|
|
||||||
playVideo,
|
|
||||||
stopPlayback,
|
|
||||||
setVolume,
|
|
||||||
setIsPlaying,
|
|
||||||
isPlaying,
|
|
||||||
videoRef,
|
|
||||||
presentFullscreenPlayer,
|
|
||||||
onProgress,
|
|
||||||
} = usePlayback();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const aBottom = useSharedValue(0);
|
|
||||||
const aPadding = useSharedValue(0);
|
|
||||||
const aHeight = useSharedValue(100);
|
|
||||||
const router = useRouter();
|
|
||||||
const animatedOuterStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
bottom: withTiming(aBottom.value, { duration: 500 }),
|
|
||||||
height: withTiming(aHeight.value, { duration: 500 }),
|
|
||||||
padding: withTiming(aPadding.value, { duration: 500 }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const aPaddingBottom = useSharedValue(30);
|
|
||||||
const aPaddingInner = useSharedValue(12);
|
|
||||||
const aBorderRadiusBottom = useSharedValue(12);
|
|
||||||
const animatedInnerStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
padding: withTiming(aPaddingInner.value, { duration: 500 }),
|
|
||||||
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
|
|
||||||
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
|
|
||||||
duration: 500,
|
|
||||||
}),
|
|
||||||
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
|
|
||||||
duration: 500,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const from = useMemo(() => segments[2], [segments]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (segments.find((s) => s.includes("tabs"))) {
|
|
||||||
// Tab screen - i.e. home
|
|
||||||
aBottom.value = Platform.OS === "ios" ? 78 : 50;
|
|
||||||
aHeight.value = 80;
|
|
||||||
aPadding.value = 8;
|
|
||||||
aPaddingBottom.value = 8;
|
|
||||||
aPaddingInner.value = 8;
|
|
||||||
} else {
|
|
||||||
// Inside a normal screen
|
|
||||||
aBottom.value = Platform.OS === "ios" ? 0 : 0;
|
|
||||||
aHeight.value = Platform.OS === "ios" ? 110 : 80;
|
|
||||||
aPadding.value = Platform.OS === "ios" ? 0 : 8;
|
|
||||||
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
|
|
||||||
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
|
|
||||||
}
|
|
||||||
}, [segments]);
|
|
||||||
|
|
||||||
const startPosition = useMemo(
|
|
||||||
() =>
|
|
||||||
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(
|
|
||||||
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
|
||||||
)
|
|
||||||
: 0,
|
|
||||||
[currentlyPlaying?.item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (currentlyPlaying?.item.Type === "Audio")
|
|
||||||
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
|
|
||||||
else
|
|
||||||
return getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: currentlyPlaying?.item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [currentlyPlaying?.item.Id, api]);
|
|
||||||
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!api || !currentlyPlaying || !poster) return null;
|
|
||||||
return {
|
|
||||||
uri: currentlyPlaying.url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: currentlyPlaying.item?.AlbumArtist
|
|
||||||
? currentlyPlaying.item?.AlbumArtist
|
|
||||||
: undefined,
|
|
||||||
title: currentlyPlaying.item?.Name || "Unknown",
|
|
||||||
description: currentlyPlaying.item?.Overview
|
|
||||||
? currentlyPlaying.item?.Overview
|
|
||||||
: undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: currentlyPlaying.item?.Album
|
|
||||||
? currentlyPlaying.item?.Album
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [currentlyPlaying, startPosition, api, poster]);
|
|
||||||
|
|
||||||
if (!api || !currentlyPlaying) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
style={[animatedOuterStyle]}
|
|
||||||
className="absolute left-0 w-screen"
|
|
||||||
>
|
|
||||||
<BlurView
|
|
||||||
intensity={Platform.OS === "android" ? 60 : 100}
|
|
||||||
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
|
|
||||||
className={`h-full w-full rounded-xl overflow-hidden ${
|
|
||||||
Platform.OS === "android" && "bg-black"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
|
|
||||||
animatedInnerStyle,
|
|
||||||
]}
|
|
||||||
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
|
|
||||||
>
|
|
||||||
<View className="flex flex-row items-center space-x-4 shrink">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
videoRef.current?.presentFullscreenPlayer();
|
|
||||||
}}
|
|
||||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
|
||||||
${
|
|
||||||
currentlyPlaying.item?.Type === "Audio"
|
|
||||||
? "aspect-square"
|
|
||||||
: "aspect-video"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{videoSource && (
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
allowsExternalPlayback
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
playWhenInactive={true}
|
|
||||||
playInBackground={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
controls={false}
|
|
||||||
pictureInPicture={true}
|
|
||||||
poster={
|
|
||||||
poster && currentlyPlaying.item?.Type === "Audio"
|
|
||||||
? poster
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
debug={{
|
|
||||||
enable: true,
|
|
||||||
thread: true,
|
|
||||||
}}
|
|
||||||
onProgress={(e) => onProgress(e)}
|
|
||||||
subtitleStyle={{
|
|
||||||
fontSize: 16,
|
|
||||||
}}
|
|
||||||
source={videoSource}
|
|
||||||
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
presentFullscreenPlayer();
|
|
||||||
}, 300);
|
|
||||||
}}
|
|
||||||
onFullscreenPlayerDidDismiss={() => {}}
|
|
||||||
onFullscreenPlayerDidPresent={() => {}}
|
|
||||||
onPlaybackStateChanged={(e) => {
|
|
||||||
if (e.isPlaying === true) {
|
|
||||||
playVideo(false);
|
|
||||||
} else if (e.isPlaying === false) {
|
|
||||||
pauseVideo(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onVolumeChange={(e) => {
|
|
||||||
setVolume(e.volume);
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={4000}
|
|
||||||
onError={(e) => {
|
|
||||||
console.log(e);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
"Video playback error: " + JSON.stringify(e)
|
|
||||||
);
|
|
||||||
Alert.alert("Error", "Cannot play this video file.");
|
|
||||||
setIsPlaying(false);
|
|
||||||
// setCurrentlyPlaying(null);
|
|
||||||
}}
|
|
||||||
renderLoader={
|
|
||||||
currentlyPlaying.item?.Type !== "Audio" && (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View className="shrink text-xs">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (currentlyPlaying.item?.Type === "Audio") {
|
|
||||||
router.push(
|
|
||||||
// @ts-ignore
|
|
||||||
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
router.push(
|
|
||||||
// @ts-ignore
|
|
||||||
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{currentlyPlaying.item?.Type === "Episode" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push(
|
|
||||||
// @ts-ignore
|
|
||||||
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="text-xs opacity-50"
|
|
||||||
>
|
|
||||||
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{currentlyPlaying.item?.Type === "Movie" && (
|
|
||||||
<View>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{currentlyPlaying.item?.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{currentlyPlaying.item?.Type === "Audio" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{currentlyPlaying.item?.Album}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center space-x-2">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (isPlaying) pauseVideo();
|
|
||||||
else playVideo();
|
|
||||||
}}
|
|
||||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
{isPlaying ? (
|
|
||||||
<Ionicons name="pause" size={24} color="white" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="play" size={24} color="white" />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
stopPlayback();
|
|
||||||
}}
|
|
||||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</BlurView>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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();
|
||||||
@@ -123,7 +126,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay) {
|
if (mediaSource.SupportsDirectPlay) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
console.log("Using direct stream for video!");
|
|
||||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
console.log("Using direct stream for audio!");
|
console.log("Using direct stream for audio!");
|
||||||
@@ -146,7 +148,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
}/universal?${searchParams.toString()}`;
|
}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
} else if (mediaSource.TranscodingUrl) {
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
console.log("Using transcoded stream!");
|
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,14 +287,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"
|
||||||
>
|
>
|
||||||
|
|||||||
700
components/FullScreenVideoPlayer.tsx
Normal file
700
components/FullScreenVideoPlayer.tsx
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
||||||
|
import { useControlsVisibility } from "@/hooks/useControlsVisibility";
|
||||||
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppState,
|
||||||
|
AppStateStatus,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import Video, { OnProgressData } from "react-native-video";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { itemRouter } from "./common/TouchableItemRouter";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
|
||||||
|
async function setOrientation(orientation: ScreenOrientation.OrientationLock) {
|
||||||
|
await ScreenOrientation.lockAsync(orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetOrientation() {
|
||||||
|
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FullScreenVideoPlayer: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentlyPlaying,
|
||||||
|
pauseVideo,
|
||||||
|
playVideo,
|
||||||
|
stopPlayback,
|
||||||
|
setVolume,
|
||||||
|
setIsPlaying,
|
||||||
|
isPlaying,
|
||||||
|
videoRef,
|
||||||
|
onProgress,
|
||||||
|
isBuffering: _isBuffering,
|
||||||
|
setIsBuffering,
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
|
||||||
|
useTrickplay(currentlyPlaying);
|
||||||
|
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
|
||||||
|
const { showControls, hideControls, opacity } = useControlsVisibility(3000);
|
||||||
|
const [isInteractive, setIsInteractive] = useState(true);
|
||||||
|
|
||||||
|
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
|
||||||
|
const from = useMemo(() => segments[2], [segments]);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
|
||||||
|
const sliding = useRef(false);
|
||||||
|
const localIsBuffering = useSharedValue(false);
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||||
|
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!currentlyPlaying?.item || !api) return "";
|
||||||
|
return currentlyPlaying.item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: currentlyPlaying.item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [currentlyPlaying?.item, api]);
|
||||||
|
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!api || !currentlyPlaying || !poster) return null;
|
||||||
|
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
uri: currentlyPlaying.url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
|
||||||
|
title: currentlyPlaying.item?.Name || "Unknown",
|
||||||
|
description: currentlyPlaying.item?.Overview ?? undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: currentlyPlaying.item?.Album ?? undefined, // Change here
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [currentlyPlaying, api, poster]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
if (nextAppState === "active") {
|
||||||
|
setIsInteractive(true);
|
||||||
|
showControls();
|
||||||
|
} else {
|
||||||
|
setIsInteractive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [showControls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
max.value = currentlyPlaying?.item.RunTimeTicks || 0;
|
||||||
|
}, [currentlyPlaying?.item.RunTimeTicks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentlyPlaying) {
|
||||||
|
resetOrientation();
|
||||||
|
progress.value = 0;
|
||||||
|
min.value = 0;
|
||||||
|
max.value = 0;
|
||||||
|
cacheProgress.value = 0;
|
||||||
|
localIsBuffering.value = false;
|
||||||
|
sliding.current = false;
|
||||||
|
hideControls();
|
||||||
|
} else {
|
||||||
|
setOrientation(
|
||||||
|
settings?.defaultVideoOrientation ||
|
||||||
|
ScreenOrientation.OrientationLock.DEFAULT
|
||||||
|
);
|
||||||
|
progress.value =
|
||||||
|
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
|
||||||
|
max.value = currentlyPlaying.item.RunTimeTicks || 0;
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
}, [currentlyPlaying, settings]);
|
||||||
|
|
||||||
|
const animatedStyles = {
|
||||||
|
controls: useAnimatedStyle(() => ({
|
||||||
|
opacity: withTiming(opacity.value, { duration: 300 }),
|
||||||
|
})),
|
||||||
|
videoContainer: useAnimatedStyle(() => ({
|
||||||
|
opacity: withTiming(
|
||||||
|
opacity.value === 1 || localIsBuffering.value ? 0.5 : 1,
|
||||||
|
{
|
||||||
|
duration: 300,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
loader: useAnimatedStyle(() => ({
|
||||||
|
opacity: withTiming(localIsBuffering.value ? 1 : 0, { duration: 300 }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: introTimestamps } = useQuery({
|
||||||
|
queryKey: ["introTimestamps", currentlyPlaying?.item.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!currentlyPlaying?.item.Id) {
|
||||||
|
console.log("No item id");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api?.axiosInstance.get(
|
||||||
|
`${api.basePath}/Episode/${currentlyPlaying.item.Id}/IntroTimestamps`,
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res?.status !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res?.data as {
|
||||||
|
EpisodeId: string;
|
||||||
|
HideSkipPromptAt: number;
|
||||||
|
IntroEnd: number;
|
||||||
|
IntroStart: number;
|
||||||
|
ShowSkipPromptAt: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!currentlyPlaying?.item.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedIntroSkipperStyle = useAnimatedStyle(() => {
|
||||||
|
const showButtonAt = secondsToTicks(introTimestamps?.ShowSkipPromptAt || 0);
|
||||||
|
const hideButtonAt = secondsToTicks(introTimestamps?.HideSkipPromptAt || 0);
|
||||||
|
const showButton =
|
||||||
|
progress.value > showButtonAt && progress.value < hideButtonAt;
|
||||||
|
return {
|
||||||
|
opacity: withTiming(
|
||||||
|
localIsBuffering.value === false && opacity.value === 1 && showButton
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
{
|
||||||
|
duration: 300,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleIgnoreSafeArea = useCallback(() => {
|
||||||
|
setIgnoreSafeArea((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleControlsPress = useCallback(() => {
|
||||||
|
if (opacity.value === 1) {
|
||||||
|
hideControls();
|
||||||
|
} else {
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
}, [opacity.value, hideControls, showControls]);
|
||||||
|
|
||||||
|
const skipIntro = useCallback(async () => {
|
||||||
|
if (!introTimestamps || !videoRef.current) return;
|
||||||
|
try {
|
||||||
|
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
|
}
|
||||||
|
}, [introTimestamps]);
|
||||||
|
|
||||||
|
const handleVideoProgress = useCallback(
|
||||||
|
(e: OnProgressData) => {
|
||||||
|
if (e.playableDuration === 0) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
localIsBuffering.value = true;
|
||||||
|
} else {
|
||||||
|
setIsBuffering(false);
|
||||||
|
localIsBuffering.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sliding.current) return;
|
||||||
|
onProgress(e);
|
||||||
|
progress.value = secondsToTicks(e.currentTime);
|
||||||
|
cacheProgress.value = secondsToTicks(e.playableDuration);
|
||||||
|
},
|
||||||
|
[onProgress, setIsBuffering]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVideoError = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
console.log(e);
|
||||||
|
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
|
||||||
|
Alert.alert("Error", "Cannot play this video file.");
|
||||||
|
setIsPlaying(false);
|
||||||
|
},
|
||||||
|
[setIsPlaying]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr - 15));
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
}, [videoRef, showControls]);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr + 15));
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
}, [videoRef, showControls]);
|
||||||
|
|
||||||
|
const handlePlayPause = useCallback(() => {
|
||||||
|
if (isPlaying) pauseVideo();
|
||||||
|
else playVideo();
|
||||||
|
showControls();
|
||||||
|
}, [isPlaying, pauseVideo, playVideo, showControls]);
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
sliding.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSliderComplete = useCallback(
|
||||||
|
(val: number) => {
|
||||||
|
const tick = Math.floor(val);
|
||||||
|
videoRef.current?.seek(tick / 10000000);
|
||||||
|
sliding.current = false;
|
||||||
|
},
|
||||||
|
[videoRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSliderChange = useCallback(
|
||||||
|
(val: number) => {
|
||||||
|
const tick = Math.floor(val);
|
||||||
|
progress.value = tick;
|
||||||
|
calculateTrickplayUrl(progress);
|
||||||
|
showControls();
|
||||||
|
},
|
||||||
|
[progress, calculateTrickplayUrl, showControls]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGoToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem || !from) return;
|
||||||
|
const url = itemRouter(previousItem, from);
|
||||||
|
stopPlayback();
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
|
}, [previousItem, from, stopPlayback, router]);
|
||||||
|
|
||||||
|
const handleGoToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem || !from) return;
|
||||||
|
const url = itemRouter(nextItem, from);
|
||||||
|
stopPlayback();
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
|
}, [nextItem, from, stopPlayback, router]);
|
||||||
|
|
||||||
|
const videoTap = Gesture.Tap().onBegin(() => {
|
||||||
|
runOnJS(handleToggleControlsPress)();
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleIgnoreSafeAreaGesture = Gesture.Tap()
|
||||||
|
.enabled(opacity.value !== 0)
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(toggleIgnoreSafeArea)();
|
||||||
|
});
|
||||||
|
|
||||||
|
const playPauseGesture = Gesture.Tap()
|
||||||
|
.enabled(opacity.value !== 0)
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(handlePlayPause)();
|
||||||
|
});
|
||||||
|
|
||||||
|
const goToPreviouItemGesture = Gesture.Tap()
|
||||||
|
.enabled(opacity.value !== 0)
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(handleGoToPreviousItem)();
|
||||||
|
});
|
||||||
|
|
||||||
|
const goToNextItemGesture = Gesture.Tap()
|
||||||
|
.enabled(opacity.value !== 0)
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(handleGoToNextItem)();
|
||||||
|
});
|
||||||
|
|
||||||
|
const skipBackwardGesture = Gesture.Tap()
|
||||||
|
.enabled(opacity.value !== 0)
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(handleSkipBackward)();
|
||||||
|
});
|
||||||
|
|
||||||
|
const skipForwardGesture = Gesture.Tap()
|
||||||
|
.enabled(opacity.value !== 0)
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(handleSkipForward)();
|
||||||
|
});
|
||||||
|
|
||||||
|
const skipIntroGesture = Gesture.Tap()
|
||||||
|
.enabled(opacity.value !== 0)
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(skipIntro)();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!api || !currentlyPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenHeight,
|
||||||
|
backgroundColor: "black",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GestureDetector gesture={videoTap}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: ignoreSafeArea ? 0 : insets.left,
|
||||||
|
right: ignoreSafeArea ? 0 : insets.right,
|
||||||
|
width: ignoreSafeArea
|
||||||
|
? screenWidth
|
||||||
|
: screenWidth - (insets.left + insets.right),
|
||||||
|
},
|
||||||
|
animatedStyles.videoContainer,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{videoSource && (
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
allowsExternalPlayback
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
resizeMode="contain"
|
||||||
|
playWhenInactive={true}
|
||||||
|
playInBackground={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
controls={false}
|
||||||
|
pictureInPicture={true}
|
||||||
|
onProgress={handleVideoProgress}
|
||||||
|
subtitleStyle={{
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
source={videoSource}
|
||||||
|
onPlaybackStateChanged={(e) => {
|
||||||
|
if (e.isPlaying === true) {
|
||||||
|
playVideo(false);
|
||||||
|
} else if (e.isPlaying === false) {
|
||||||
|
pauseVideo(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBuffer={(e) => {
|
||||||
|
if (e.isBuffering) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
localIsBuffering.value = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||||
|
showControls();
|
||||||
|
}}
|
||||||
|
onVolumeChange={(e) => {
|
||||||
|
setVolume(e.volume);
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={1000}
|
||||||
|
onError={handleVideoError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute" as const,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: ignoreSafeArea ? 0 : insets.left,
|
||||||
|
right: ignoreSafeArea ? 0 : insets.right,
|
||||||
|
width: ignoreSafeArea
|
||||||
|
? screenWidth
|
||||||
|
: screenWidth - (insets.left + insets.right),
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
animatedStyles.loader,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 8 * 8,
|
||||||
|
right: insets.right + 32,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
animatedIntroSkipperStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center h-full">
|
||||||
|
<TouchableOpacity className="flex flex-col items-center justify-center px-2 py-1.5 bg-purple-600 rounded-full">
|
||||||
|
<GestureDetector gesture={skipIntroGesture}>
|
||||||
|
<Text>Skip intro</Text>
|
||||||
|
</GestureDetector>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
right: insets.right + 20,
|
||||||
|
height: 70,
|
||||||
|
},
|
||||||
|
animatedStyles.controls,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center h-full">
|
||||||
|
<GestureDetector gesture={toggleIgnoreSafeAreaGesture}>
|
||||||
|
<TouchableOpacity className="aspect-square rounded flex flex-col items-center justify-center p-2">
|
||||||
|
<Ionicons
|
||||||
|
name={ignoreSafeArea ? "contract-outline" : "expand"}
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
stopPlayback();
|
||||||
|
}}
|
||||||
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 8,
|
||||||
|
left: insets.left + 32,
|
||||||
|
width: screenWidth - insets.left - insets.right - 64,
|
||||||
|
borderRadius: 100,
|
||||||
|
},
|
||||||
|
animatedStyles.controls,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||||
|
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
|
||||||
|
{currentlyPlaying.item?.Type === "Episode" && (
|
||||||
|
<Text className="opacity-50">
|
||||||
|
{currentlyPlaying.item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Movie" && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Audio" && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.Album}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center space-x-6 rounded-full py-2 pl-4 pr-4 bg-neutral-800">
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !previousItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GestureDetector gesture={goToPreviouItemGesture}>
|
||||||
|
<Ionicons name="play-skip-back" size={20} color="white" />
|
||||||
|
</GestureDetector>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<GestureDetector gesture={skipBackwardGesture}>
|
||||||
|
<Ionicons
|
||||||
|
name="refresh-outline"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<GestureDetector gesture={playPauseGesture}>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={26}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<GestureDetector gesture={skipForwardGesture}>
|
||||||
|
<Ionicons name="refresh-outline" size={24} color="white" />
|
||||||
|
</GestureDetector>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !nextItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GestureDetector gesture={goToNextItemGesture}>
|
||||||
|
<Ionicons name="play-skip-forward" size={20} color="white" />
|
||||||
|
</GestureDetector>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col w-full shrink">
|
||||||
|
<Slider
|
||||||
|
disable={opacity.value === 0}
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
bubbleBackgroundColor: "#fff",
|
||||||
|
bubbleTextColor: "#000",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
cache={cacheProgress}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
renderBubble={() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
marginLeft: -tileWidth / 4,
|
||||||
|
marginTop: -tileHeight / 4 - 60,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
className=" bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sliderHeight={10}
|
||||||
|
thumbWidth={0}
|
||||||
|
progress={progress}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
/>
|
||||||
|
<View className="flex flex-row items-center justify-between -mb-0.5">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{runtimeTicksToSeconds(progress.value)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{runtimeTicksToSeconds(max.value - progress.value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -24,24 +26,23 @@ import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { Stack, 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);
|
||||||
@@ -55,13 +56,12 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
useState<MediaSourceInfo | null>(null);
|
useState<MediaSourceInfo | null>(null);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(-1);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
key: "Max",
|
key: "Max",
|
||||||
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,
|
||||||
@@ -117,6 +117,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
itemId: id,
|
itemId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("itemID", res?.Id);
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
enabled: !!id && !!api,
|
enabled: !!id && !!api,
|
||||||
@@ -166,6 +168,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 +235,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 +275,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 +286,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>
|
||||||
|
|||||||
@@ -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;
|
}, [url]);
|
||||||
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.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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, { opacity: 0.5 }]}
|
||||||
|
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,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -21,7 +22,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent" && Platform.OS !== "android")
|
||||||
return (
|
return (
|
||||||
<BlurView
|
<BlurView
|
||||||
{...props}
|
{...props}
|
||||||
@@ -52,7 +53,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
className="drop-shadow-2xl"
|
className="drop-shadow-2xl"
|
||||||
name="arrow-back"
|
name="arrow-back"
|
||||||
size={24}
|
size={24}
|
||||||
color="#077DF2"
|
color="white"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,12 +2,48 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||||
|
if (item.Type === "Series") {
|
||||||
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "MusicAlbum") {
|
||||||
|
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Audio") {
|
||||||
|
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "MusicArtist") {
|
||||||
|
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Person") {
|
||||||
|
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "BoxSet") {
|
||||||
|
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "UserView") {
|
||||||
|
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "CollectionFolder") {
|
||||||
|
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
item,
|
item,
|
||||||
children,
|
children,
|
||||||
@@ -23,54 +59,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
const url = itemRouter(item, from);
|
||||||
if (item.Type === "Series") {
|
// @ts-ignore
|
||||||
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
|
router.push(url);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicAlbum") {
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Audio") {
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicArtist") {
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Person") {
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "BoxSet") {
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "UserView") {
|
|
||||||
Alert.alert("Not implemented");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "CollectionFolder") {
|
|
||||||
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same as default
|
|
||||||
// if (item.Type === "Episode") {
|
|
||||||
// router.push(`/items/${item.Id}`);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -127,7 +125,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
{library.Name}
|
{library.Name}
|
||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
|
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
||||||
{itemsCount} items
|
{itemsCount} items
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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,17 +1,32 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
|
import {
|
||||||
|
DefaultLanguageOption,
|
||||||
|
DownloadOptions,
|
||||||
|
ScreenOrientationEnum,
|
||||||
|
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";
|
||||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
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";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
|
||||||
export const SettingToggles: React.FC = () => {
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -43,315 +58,387 @@ export const SettingToggles: React.FC = () => {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
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 {...props}>
|
||||||
<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>
|
<View>
|
||||||
<Text className="text-xs opacity-50 shrink">
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
Open all videos in VLC instead of the default player. This requries
|
|
||||||
VLC to be installed on the phone.
|
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
</Text>
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
</View>
|
<View className="shrink">
|
||||||
<Switch
|
<Text className="font-semibold">Auto rotate</Text>
|
||||||
value={settings?.openInVLC}
|
<Text className="text-xs opacity-50">
|
||||||
onValueChange={(value) => {
|
Important on android since the video player orientation is
|
||||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
locked to the app orientation.
|
||||||
}}
|
</Text>
|
||||||
/>
|
</View>
|
||||||
</View>
|
<Switch
|
||||||
|
value={settings.autoRotate}
|
||||||
|
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||||
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
Open all videos in VLC instead of the default player. This
|
||||||
|
requries VLC to be installed on the phone.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.openInVLC}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
<View className="flex flex-col">
|
||||||
<TouchableOpacity
|
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||||
onPress={() => {
|
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||||
Linking.openURL(
|
<TouchableOpacity
|
||||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-xs text-purple-600">More info</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings?.usePopularPlugin}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ usePopularPlugin: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{settings?.usePopularPlugin && (
|
|
||||||
<View className="flex flex-col py-2 bg-neutral-900">
|
|
||||||
{mediaListCollections?.map((mlc) => (
|
|
||||||
<View
|
|
||||||
key={mlc.Id}
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
|
||||||
>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">{mlc.Name}</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings?.mediaListCollectionIds?.includes(mlc.Id!)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!settings.mediaListCollectionIds) {
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds: [mlc.Id!],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds:
|
|
||||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
|
||||||
? settings?.mediaListCollectionIds.filter(
|
|
||||||
(id) => id !== mlc.Id
|
|
||||||
)
|
|
||||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{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 className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Force direct play</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
This will always request direct play. This is good if you want to
|
|
||||||
try to stream movies you think the device supports.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings?.forceDirectPlay}
|
|
||||||
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Device profile</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
A profile used for deciding what audio and video codecs the device
|
|
||||||
supports.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings?.deviceProfile}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Expo" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Native" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Old" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Search engine</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose the search engine you want to use.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings?.searchEngine}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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={() => {
|
onPress={() => {
|
||||||
updateSettings({ marlinServerUrl: marlinUrl });
|
Linking.openURL(
|
||||||
|
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
</Button>
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
<Text className="text-neutral-500 mt-2">
|
updateSettings({
|
||||||
{settings?.marlinServerUrl}
|
mediaListCollectionIds:
|
||||||
</Text>
|
settings.mediaListCollectionIds.includes(mlc.Id!)
|
||||||
</>
|
? settings.mediaListCollectionIds.filter(
|
||||||
|
(id) => id !== mlc.Id
|
||||||
|
)
|
||||||
|
: [...settings.mediaListCollectionIds, mlc.Id!],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{isLoadingMediaListCollections && (
|
||||||
|
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{mediaListCollections?.length === 0 && (
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
No collections found. Add some in Jellyfin.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Force direct play</Text>
|
||||||
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
This will always request direct play. This is good if you want
|
||||||
|
to try to stream movies you think the device supports.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.forceDirectPlay}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ forceDirectPlay: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Device profile</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
A profile used for deciding what audio and video codecs the
|
||||||
|
device supports.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>{settings.deviceProfile}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ deviceProfile: "Expo" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ deviceProfile: "Native" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="3"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ deviceProfile: "Old" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Video orientation</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Set the full screen video player orientation.
|
||||||
|
</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>
|
||||||
|
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.DEFAULT
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="3"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="4"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const routes = [
|
|||||||
"albums/[albumId]",
|
"albums/[albumId]",
|
||||||
"artists/index",
|
"artists/index",
|
||||||
"artists/[artistId]",
|
"artists/[artistId]",
|
||||||
"collections/[collectionId]",
|
|
||||||
"items/page",
|
"items/page",
|
||||||
"series/[id]",
|
"series/[id]",
|
||||||
];
|
];
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.10.2",
|
"channel": "0.14.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.10.2",
|
"channel": "0.14.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
76
hooks/useAdjacentEpisodes.ts
Normal file
76
hooks/useAdjacentEpisodes.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
interface AdjacentEpisodesProps {
|
||||||
|
currentlyPlaying?: CurrentlyPlayingState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdjacentEpisodes = ({
|
||||||
|
currentlyPlaying,
|
||||||
|
}: AdjacentEpisodesProps) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const { data: previousItem } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"previousItem",
|
||||||
|
currentlyPlaying?.item.ParentId,
|
||||||
|
currentlyPlaying?.item.IndexNumber,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||||
|
if (
|
||||||
|
!api ||
|
||||||
|
!currentlyPlaying?.item.ParentId ||
|
||||||
|
currentlyPlaying?.item.IndexNumber === undefined ||
|
||||||
|
currentlyPlaying?.item.IndexNumber === null ||
|
||||||
|
currentlyPlaying.item.IndexNumber - 2 < 0
|
||||||
|
) {
|
||||||
|
console.log("No previous item");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getItemsApi(api).getItems({
|
||||||
|
parentId: currentlyPlaying.item.ParentId!,
|
||||||
|
startIndex: currentlyPlaying.item.IndexNumber! - 2,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.Items?.[0] || null;
|
||||||
|
},
|
||||||
|
enabled: currentlyPlaying?.item.Type === "Episode",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: nextItem } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"nextItem",
|
||||||
|
currentlyPlaying?.item.ParentId,
|
||||||
|
currentlyPlaying?.item.IndexNumber,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||||
|
if (
|
||||||
|
!api ||
|
||||||
|
!currentlyPlaying?.item.ParentId ||
|
||||||
|
currentlyPlaying?.item.IndexNumber === undefined ||
|
||||||
|
currentlyPlaying?.item.IndexNumber === null
|
||||||
|
) {
|
||||||
|
console.log("No next item");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getItemsApi(api).getItems({
|
||||||
|
parentId: currentlyPlaying.item.ParentId!,
|
||||||
|
startIndex: currentlyPlaying.item.IndexNumber!,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.Items?.[0] || null;
|
||||||
|
},
|
||||||
|
enabled: currentlyPlaying?.item.Type === "Episode",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousItem, nextItem };
|
||||||
|
};
|
||||||
41
hooks/useControlsVisibility.ts
Normal file
41
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
export const useControlsVisibility = (timeout: number = 3000) => {
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
opacity.value = 1;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
}, timeout);
|
||||||
|
}, [timeout]);
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { opacity, showControls, hideControls };
|
||||||
|
};
|
||||||
@@ -1,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",
|
||||||
@@ -24,7 +28,7 @@ export const useImageColors = (uri: string | undefined | null) => {
|
|||||||
secondary = colors.muted;
|
secondary = colors.muted;
|
||||||
} else if (colors.platform === "ios") {
|
} else if (colors.platform === "ios") {
|
||||||
primary = colors.primary;
|
primary = colors.primary;
|
||||||
secondary = colors.detail;
|
secondary = colors.secondary;
|
||||||
average = colors.background;
|
average = colors.background;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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]);
|
||||||
};
|
};
|
||||||
|
|||||||
27
hooks/useNavigationBarVisibility.ts
Normal file
27
hooks/useNavigationBarVisibility.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// hooks/useNavigationBarVisibility.ts
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
|
||||||
|
export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibility = async () => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
if (isPlaying) {
|
||||||
|
await NavigationBar.setVisibilityAsync("hidden");
|
||||||
|
} else {
|
||||||
|
await NavigationBar.setVisibilityAsync("visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleVisibility();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setVisibilityAsync("visible");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying]);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -28,6 +29,10 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
|
toast.success("Download started", {
|
||||||
|
invert: true,
|
||||||
|
});
|
||||||
|
|
||||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
|
||||||
writeToLog(
|
writeToLog(
|
||||||
|
|||||||
100
hooks/useTrickplay.ts
Normal file
100
hooks/useTrickplay.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// hooks/useTrickplay.ts
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { SharedValue } from "react-native-reanimated";
|
||||||
|
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
interface TrickplayData {
|
||||||
|
Interval?: number;
|
||||||
|
TileWidth?: number;
|
||||||
|
TileHeight?: number;
|
||||||
|
Height?: number;
|
||||||
|
Width?: number;
|
||||||
|
ThumbnailCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrickplayInfo {
|
||||||
|
resolution: string;
|
||||||
|
aspectRatio: number;
|
||||||
|
data: TrickplayData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrickplayUrl {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTrickplay = (
|
||||||
|
currentlyPlaying?: CurrentlyPlayingState | null
|
||||||
|
) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||||
|
|
||||||
|
const trickplayInfo = useMemo(() => {
|
||||||
|
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaSourceId = currentlyPlaying.item.Id;
|
||||||
|
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
|
||||||
|
|
||||||
|
if (!trickplayData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first available resolution
|
||||||
|
const firstResolution = Object.keys(trickplayData)[0];
|
||||||
|
return firstResolution
|
||||||
|
? {
|
||||||
|
resolution: firstResolution,
|
||||||
|
aspectRatio:
|
||||||
|
trickplayData[firstResolution].Width! /
|
||||||
|
trickplayData[firstResolution].Height!,
|
||||||
|
data: trickplayData[firstResolution],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}, [currentlyPlaying]);
|
||||||
|
|
||||||
|
const calculateTrickplayUrl = useCallback(
|
||||||
|
(progress: SharedValue<number>) => {
|
||||||
|
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, resolution } = trickplayInfo;
|
||||||
|
const { Interval, TileWidth, TileHeight } = data;
|
||||||
|
|
||||||
|
if (!Interval || !TileWidth || !TileHeight || !resolution) {
|
||||||
|
throw new Error("Invalid trickplay data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSecond = Math.max(0, Math.floor(progress.value / 10000000));
|
||||||
|
|
||||||
|
const cols = TileWidth;
|
||||||
|
const rows = TileHeight;
|
||||||
|
const imagesPerTile = cols * rows;
|
||||||
|
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
|
||||||
|
const tileIndex = Math.floor(imageIndex / imagesPerTile);
|
||||||
|
|
||||||
|
const positionInTile = imageIndex % imagesPerTile;
|
||||||
|
const rowInTile = Math.floor(positionInTile / cols);
|
||||||
|
const colInTile = positionInTile % cols;
|
||||||
|
|
||||||
|
const newTrickPlayUrl = {
|
||||||
|
x: rowInTile,
|
||||||
|
y: colInTile,
|
||||||
|
url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTrickPlayUrl(newTrickPlayUrl);
|
||||||
|
return newTrickPlayUrl;
|
||||||
|
},
|
||||||
|
[trickplayInfo, currentlyPlaying, api]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
|
||||||
|
};
|
||||||
22
package.json
22
package.json
@@ -20,29 +20,29 @@
|
|||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
"@gorhom/bottom-sheet": "^4",
|
"@gorhom/bottom-sheet": "^4",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.10.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.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.56.2",
|
||||||
"@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,29 +52,31 @@
|
|||||||
"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",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
|
"react-native-awesome-slider": "^2.5.3",
|
||||||
"react-native-circular-progress": "^1.4.0",
|
"react-native-circular-progress": "^1.4.0",
|
||||||
"react-native-compressor": "^1.8.25",
|
"react-native-compressor": "^1.8.25",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.2",
|
"react-native-google-cast": "^4.8.3",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^2.5.1",
|
"react-native-ios-context-menu": "^2.5.1",
|
||||||
"react-native-ios-utilities": "^4.4.5",
|
"react-native-ios-utilities": "^4.4.5",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-video": "^6.4.5",
|
"react-native-video": "^6.5.0",
|
||||||
"react-native-web": "~0.19.10",
|
"react-native-web": "~0.19.10",
|
||||||
|
"sonner-native": "^0.9.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.3",
|
"use-debounce": "^10.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
|
|||||||
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.14.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.14.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ import { getMediaInfoApi } 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 } 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 {
|
||||||
|
parseM3U8ForSubtitles,
|
||||||
|
SubtitleTrack,
|
||||||
|
} from "@/utils/hls/parseM3U8ForSubtitles";
|
||||||
|
|
||||||
type CurrentlyPlayingState = {
|
export type CurrentlyPlayingState = {
|
||||||
url: string;
|
url: string;
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
};
|
};
|
||||||
@@ -45,11 +49,17 @@ interface PlaybackContextType {
|
|||||||
dismissFullscreenPlayer: () => void;
|
dismissFullscreenPlayer: () => void;
|
||||||
setIsFullscreen: (isFullscreen: boolean) => void;
|
setIsFullscreen: (isFullscreen: boolean) => void;
|
||||||
setIsPlaying: (isPlaying: boolean) => void;
|
setIsPlaying: (isPlaying: boolean) => void;
|
||||||
|
isBuffering: boolean;
|
||||||
|
setIsBuffering: (val: boolean) => void;
|
||||||
onProgress: (data: OnProgressData) => void;
|
onProgress: (data: OnProgressData) => void;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
setCurrentlyPlayingState: (
|
setCurrentlyPlayingState: (
|
||||||
currentlyPlaying: CurrentlyPlayingState | null
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
) => void;
|
) => void;
|
||||||
|
startDownloadedFilePlayback: (
|
||||||
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
|
) => void;
|
||||||
|
subtitles: SubtitleTrack[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||||
@@ -67,10 +77,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const previousVolume = useRef<number | null>(null);
|
const previousVolume = useRef<number | null>(null);
|
||||||
|
|
||||||
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState<boolean>(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||||
const [volume, _setVolume] = useState<number | null>(null);
|
const [volume, _setVolume] = useState<number | null>(null);
|
||||||
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
||||||
|
const [subtitles, setSubtitles] = useState<SubtitleTrack[]>([]);
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] =
|
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||||
useState<CurrentlyPlayingState | null>(null);
|
useState<CurrentlyPlayingState | null>(null);
|
||||||
|
|
||||||
@@ -92,41 +104,69 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
queryFn: getDeviceId,
|
queryFn: getDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const startDownloadedFilePlayback = useCallback(
|
||||||
|
async (state: CurrentlyPlayingState | null) => {
|
||||||
|
if (!state) {
|
||||||
|
setCurrentlyPlaying(null);
|
||||||
|
setIsPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentlyPlaying(state);
|
||||||
|
setIsPlaying(true);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const setCurrentlyPlayingState = useCallback(
|
const setCurrentlyPlayingState = useCallback(
|
||||||
async (state: CurrentlyPlayingState | null) => {
|
async (state: CurrentlyPlayingState | null) => {
|
||||||
if (!api) return;
|
try {
|
||||||
|
if (state?.item.Id && user?.Id) {
|
||||||
|
const vlcLink = "vlc://" + state?.url;
|
||||||
|
if (vlcLink && settings?.openInVLC) {
|
||||||
|
Linking.openURL("vlc://" + state?.url || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state && state.item.Id && user?.Id) {
|
const res = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
const vlcLink = "vlc://" + state?.url;
|
itemId: state.item.Id,
|
||||||
if (vlcLink && settings?.openInVLC) {
|
userId: user.Id,
|
||||||
Linking.openURL("vlc://" + state?.url || "");
|
});
|
||||||
return;
|
|
||||||
|
await postCapabilities({
|
||||||
|
api,
|
||||||
|
itemId: state.item.Id,
|
||||||
|
sessionId: res.data.PlaySessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSession(res.data);
|
||||||
|
setCurrentlyPlaying(state);
|
||||||
|
setIsPlaying(true);
|
||||||
|
} else {
|
||||||
|
setCurrentlyPlaying(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
const res = await getMediaInfoApi(api).getPlaybackInfo({
|
console.error(e);
|
||||||
itemId: state.item.Id,
|
Alert.alert(
|
||||||
userId: user.Id,
|
"Something went wrong",
|
||||||
});
|
"The item could not be played. Maybe there is no internet connection?",
|
||||||
|
[
|
||||||
await postCapabilities({
|
{
|
||||||
api,
|
style: "destructive",
|
||||||
itemId: state.item.Id,
|
text: "Try force play",
|
||||||
sessionId: res.data.PlaySessionId,
|
onPress: () => {
|
||||||
});
|
setCurrentlyPlaying(state);
|
||||||
|
setIsPlaying(true);
|
||||||
setSession(res.data);
|
},
|
||||||
setCurrentlyPlaying(state);
|
},
|
||||||
setIsPlaying(true);
|
{
|
||||||
|
text: "Ok",
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
style: "default",
|
||||||
setTimeout(() => {
|
},
|
||||||
presentFullscreenPlayer();
|
]
|
||||||
}, 300);
|
);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setCurrentlyPlaying(null);
|
|
||||||
setIsFullscreen(false);
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[settings, user, api]
|
[settings, user, api]
|
||||||
@@ -167,13 +207,15 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stopPlayback = useCallback(async () => {
|
const stopPlayback = useCallback(async () => {
|
||||||
|
const id = currentlyPlaying?.item?.Id;
|
||||||
|
setCurrentlyPlayingState(null);
|
||||||
|
|
||||||
await reportPlaybackStopped({
|
await reportPlaybackStopped({
|
||||||
api,
|
api,
|
||||||
itemId: currentlyPlaying?.item?.Id,
|
itemId: id,
|
||||||
sessionId: session?.PlaySessionId,
|
sessionId: session?.PlaySessionId,
|
||||||
positionTicks: progressTicks ? progressTicks : 0,
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
});
|
});
|
||||||
setCurrentlyPlayingState(null);
|
|
||||||
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
|
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
|
||||||
|
|
||||||
const setIsPlaying = useCallback(
|
const setIsPlaying = useCallback(
|
||||||
@@ -207,7 +249,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
debounce((e: OnProgressData) => {
|
debounce((e: OnProgressData) => {
|
||||||
_onProgress(e);
|
_onProgress(e);
|
||||||
}, 1000),
|
}, 500),
|
||||||
[_onProgress]
|
[_onProgress]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,7 +266,9 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deviceId || !api?.accessToken) return;
|
if (!deviceId || !api?.accessToken) return;
|
||||||
|
|
||||||
const url = `wss://${api?.basePath
|
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
|
||||||
@@ -303,6 +347,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
<PlaybackContext.Provider
|
<PlaybackContext.Provider
|
||||||
value={{
|
value={{
|
||||||
onProgress,
|
onProgress,
|
||||||
|
isBuffering,
|
||||||
|
setIsBuffering,
|
||||||
progressTicks,
|
progressTicks,
|
||||||
setVolume,
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
@@ -318,6 +364,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
stopPlayback,
|
stopPlayback,
|
||||||
presentFullscreenPlayer,
|
presentFullscreenPlayer,
|
||||||
dismissFullscreenPlayer,
|
dismissFullscreenPlayer,
|
||||||
|
startDownloadedFilePlayback,
|
||||||
|
subtitles,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -48,6 +48,19 @@ const calculateRelativeLuminance = (rgb: number[]): number => {
|
|||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCloseToBlack = (color: string): boolean => {
|
||||||
|
const r = parseInt(color.slice(1, 3), 16);
|
||||||
|
const g = parseInt(color.slice(3, 5), 16);
|
||||||
|
const b = parseInt(color.slice(5, 7), 16);
|
||||||
|
|
||||||
|
// Check if the color is very dark (close to black)
|
||||||
|
return r < 20 && g < 20 && b < 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustToNearBlack = (color: string): string => {
|
||||||
|
return "#212121"; // A very dark gray, almost black
|
||||||
|
};
|
||||||
|
|
||||||
const baseThemeColorAtom = atom<ThemeColors>({
|
const baseThemeColorAtom = atom<ThemeColors>({
|
||||||
primary: "#FFFFFF",
|
primary: "#FFFFFF",
|
||||||
secondary: "#000000",
|
secondary: "#000000",
|
||||||
@@ -59,12 +72,15 @@ export const itemThemeColorAtom = atom(
|
|||||||
(get) => get(baseThemeColorAtom),
|
(get) => get(baseThemeColorAtom),
|
||||||
(get, set, update: Partial<ThemeColors>) => {
|
(get, set, update: Partial<ThemeColors>) => {
|
||||||
const currentColors = get(baseThemeColorAtom);
|
const currentColors = get(baseThemeColorAtom);
|
||||||
const newColors = { ...currentColors, ...update };
|
let newColors = { ...currentColors, ...update };
|
||||||
|
|
||||||
|
// Adjust primary color if it's too close to black
|
||||||
|
if (newColors.primary && isCloseToBlack(newColors.primary)) {
|
||||||
|
newColors.primary = adjustToNearBlack(newColors.primary);
|
||||||
|
}
|
||||||
|
|
||||||
// Recalculate text color if primary color changes
|
// Recalculate text color if primary color changes
|
||||||
if (update.average) {
|
if (update.primary) newColors.text = calculateTextColor(newColors.primary);
|
||||||
newColors.text = calculateTextColor(update.average);
|
|
||||||
}
|
|
||||||
|
|
||||||
set(baseThemeColorAtom, newColors);
|
set(baseThemeColorAtom, newColors);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
|
||||||
export type DownloadQuality = "original" | "high" | "low";
|
export type DownloadQuality = "original" | "high" | "low";
|
||||||
|
|
||||||
@@ -9,6 +10,22 @@ export type DownloadOption = {
|
|||||||
value: DownloadQuality;
|
value: DownloadQuality;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ScreenOrientationEnum: Record<
|
||||||
|
ScreenOrientation.OrientationLock,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
[ScreenOrientation.OrientationLock.DEFAULT]: "Default",
|
||||||
|
[ScreenOrientation.OrientationLock.ALL]: "All",
|
||||||
|
[ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait",
|
||||||
|
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up",
|
||||||
|
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down",
|
||||||
|
[ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape",
|
||||||
|
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left",
|
||||||
|
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right",
|
||||||
|
[ScreenOrientation.OrientationLock.OTHER]: "Other",
|
||||||
|
[ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
export const DownloadOptions: DownloadOption[] = [
|
export const DownloadOptions: DownloadOption[] = [
|
||||||
{
|
{
|
||||||
label: "Original quality",
|
label: "Original quality",
|
||||||
@@ -32,10 +49,14 @@ 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;
|
||||||
openFullScreenVideoPlayerByDefault?: boolean;
|
|
||||||
usePopularPlugin?: boolean;
|
usePopularPlugin?: boolean;
|
||||||
deviceProfile?: "Expo" | "Native" | "Old";
|
deviceProfile?: "Expo" | "Native" | "Old";
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
@@ -45,6 +66,10 @@ type Settings = {
|
|||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
|
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||||
|
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||||
|
showHomeTitles: boolean;
|
||||||
|
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +84,6 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
const defaultValues: Settings = {
|
const defaultValues: Settings = {
|
||||||
autoRotate: true,
|
autoRotate: true,
|
||||||
forceLandscapeInVideoPlayer: false,
|
forceLandscapeInVideoPlayer: false,
|
||||||
openFullScreenVideoPlayerByDefault: false,
|
|
||||||
usePopularPlugin: false,
|
usePopularPlugin: false,
|
||||||
deviceProfile: "Expo",
|
deviceProfile: "Expo",
|
||||||
forceDirectPlay: false,
|
forceDirectPlay: false,
|
||||||
@@ -75,6 +99,10 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
showTitles: true,
|
showTitles: true,
|
||||||
showStats: true,
|
showStats: true,
|
||||||
},
|
},
|
||||||
|
defaultAudioLanguage: null,
|
||||||
|
defaultSubtitleLanguage: null,
|
||||||
|
showHomeTitles: true,
|
||||||
|
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
};
|
||||||
55
utils/hls/parseM3U8ForSubtitles.ts
Normal file
55
utils/hls/parseM3U8ForSubtitles.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export interface SubtitleTrack {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
uri: string;
|
||||||
|
language: string;
|
||||||
|
default: boolean;
|
||||||
|
forced: boolean;
|
||||||
|
autoSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseM3U8ForSubtitles(
|
||||||
|
url: string
|
||||||
|
): Promise<SubtitleTrack[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, { responseType: "text" });
|
||||||
|
const lines = response.data.split(/\r?\n/);
|
||||||
|
const subtitleTracks: SubtitleTrack[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||||
|
const attributes = parseAttributes(line);
|
||||||
|
const track: SubtitleTrack = {
|
||||||
|
index: index++,
|
||||||
|
name: attributes["NAME"] || "",
|
||||||
|
uri: attributes["URI"] || "",
|
||||||
|
language: attributes["LANGUAGE"] || "",
|
||||||
|
default: attributes["DEFAULT"] === "YES",
|
||||||
|
forced: attributes["FORCED"] === "YES",
|
||||||
|
autoSelect: attributes["AUTOSELECT"] === "YES",
|
||||||
|
};
|
||||||
|
subtitleTracks.push(track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return subtitleTracks;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttributes(line: string): { [key: string]: string } {
|
||||||
|
const attributes: { [key: string]: string } = {};
|
||||||
|
const parts = line.split(",");
|
||||||
|
parts.forEach((part) => {
|
||||||
|
const [key, value] = part.split("=");
|
||||||
|
if (key && value) {
|
||||||
|
attributes[key.trim()] = value.replace(/"/g, "").trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
PlaybackInfoResponse,
|
PlaybackInfoResponse,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
export const getStreamUrl = async ({
|
export const getStreamUrl = async ({
|
||||||
api,
|
api,
|
||||||
@@ -15,7 +16,7 @@ export const getStreamUrl = async ({
|
|||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile = ios,
|
deviceProfile = ios,
|
||||||
audioStreamIndex = 0,
|
audioStreamIndex = 0,
|
||||||
subtitleStreamIndex = 0,
|
subtitleStreamIndex = undefined,
|
||||||
forceDirectPlay = false,
|
forceDirectPlay = false,
|
||||||
height,
|
height,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
@@ -39,6 +40,9 @@ export const getStreamUrl = async ({
|
|||||||
|
|
||||||
const itemId = item.Id;
|
const itemId = item.Id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the stream URL for videos
|
||||||
|
*/
|
||||||
const response = await api.axiosInstance.post(
|
const response = await api.axiosInstance.post(
|
||||||
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
|
||||||
{
|
{
|
||||||
@@ -58,9 +62,7 @@ export const getStreamUrl = async ({
|
|||||||
EnableMpegtsM2TsMode: false,
|
EnableMpegtsM2TsMode: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: getAuthHeaders(api),
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,10 +82,8 @@ export const getStreamUrl = async ({
|
|||||||
|
|
||||||
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!");
|
|
||||||
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!");
|
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
DeviceId: api.deviceInfo.id,
|
DeviceId: api.deviceInfo.id,
|
||||||
@@ -104,7 +104,6 @@ export const getStreamUrl = async ({
|
|||||||
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
} else if (mediaSource.TranscodingUrl) {
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
console.log("Using transcoded stream!");
|
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
utils/secondsToTicks.ts
Normal file
6
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// seconds to ticks util
|
||||||
|
|
||||||
|
export function secondsToTicks(seconds: number): number {
|
||||||
|
"worklet";
|
||||||
|
return seconds * 10000000;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user