mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-25 13:33:13 +00:00
Compare commits
128 Commits
feat/syncp
...
v0.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097c428d41 | ||
|
|
c55d498592 | ||
|
|
1ce85a9d38 | ||
|
|
027c69bb7e | ||
|
|
7a20b6db7d | ||
|
|
0e8f6dc0cc | ||
|
|
bc3bdbf4c5 | ||
|
|
2bea483f08 | ||
|
|
559d8474bc | ||
|
|
83548da2c5 | ||
|
|
b21a1cd18e | ||
|
|
60981504fc | ||
|
|
f4bf0b2773 | ||
|
|
8138b37e7a | ||
|
|
1df7d8e8fe | ||
|
|
0acc1f03f0 | ||
|
|
dd1f02a13b | ||
|
|
c5c5252b89 | ||
|
|
679d6078e2 | ||
|
|
329a75a047 | ||
|
|
5f91712126 | ||
|
|
7ce2c90376 | ||
|
|
0263ad6109 | ||
|
|
05b7872022 | ||
|
|
9458d113de | ||
|
|
12cb6d4963 | ||
|
|
1c2578477a | ||
|
|
c7e10a13b5 | ||
|
|
31dbd84bec | ||
|
|
b6c6bac06a | ||
|
|
b0f7cfd013 | ||
|
|
456048a92c | ||
|
|
ff88c45d43 | ||
|
|
ddcb410df6 | ||
|
|
b3a938b53a | ||
|
|
73c43d31ee | ||
|
|
41a23d3437 | ||
|
|
92ebb29808 | ||
|
|
f46cb97e7f | ||
|
|
79020c357f | ||
|
|
a386c3a47c | ||
|
|
a46737442d | ||
|
|
41d209f3b7 | ||
|
|
d7eb25edf4 | ||
|
|
d672882c4b | ||
|
|
09dafea4ad | ||
|
|
8936a559de | ||
|
|
7c10c467f3 | ||
|
|
eff12b7350 | ||
|
|
94b6de6066 | ||
|
|
e82b154032 | ||
|
|
dd2a869929 | ||
|
|
cd6158e141 | ||
|
|
7fd232614b | ||
|
|
af0a5f54d8 | ||
|
|
3151812325 | ||
|
|
9aa0dc0a3d | ||
|
|
9fcff04c0d | ||
|
|
d1b6a265a1 | ||
|
|
ff1decfe2c | ||
|
|
a023c91877 | ||
|
|
27acd5287f | ||
|
|
ae73dab46d | ||
|
|
11f53630b5 | ||
|
|
b7465a94e9 | ||
|
|
cc97acbd4f | ||
|
|
abf7ec7d69 | ||
|
|
4dc9a6a0aa | ||
|
|
dd05ae89c3 | ||
|
|
8a60adc6b2 | ||
|
|
51376cc8c1 | ||
|
|
4eab1ebff6 | ||
|
|
76388a408c | ||
|
|
d3560c287c | ||
|
|
da78ce898c | ||
|
|
8a999a56a1 | ||
|
|
669f8d7d1a | ||
|
|
83bb5db335 | ||
|
|
7a5427099c | ||
|
|
ee9b3de7d4 | ||
|
|
bcc28d7513 | ||
|
|
d093c028d2 | ||
|
|
3032813234 | ||
|
|
4a7d8721b3 | ||
|
|
f45139ff90 | ||
|
|
65579c88e5 | ||
|
|
d716e42c20 | ||
|
|
ffe1003710 | ||
|
|
5c008f64b5 | ||
|
|
721cd093f4 | ||
|
|
3577aae7cc | ||
|
|
402bdec5ab | ||
|
|
5e141f27c4 | ||
|
|
595120229f | ||
|
|
09363bffdc | ||
|
|
c3237571a8 | ||
|
|
b67a4f1843 | ||
|
|
19a53da8a7 | ||
|
|
e3c4a291f0 | ||
|
|
25656cb7f1 | ||
|
|
7f9c893560 | ||
|
|
ce2e5e0fb8 | ||
|
|
c7703df3ce | ||
|
|
39880a6214 | ||
|
|
b7629f6f2b | ||
|
|
409e2de6c8 | ||
|
|
7cb67d73ec | ||
|
|
1fe1438ecf | ||
|
|
611f5ae37b | ||
|
|
d2701254b3 | ||
|
|
994dd44fc5 | ||
|
|
f7e04dfa2d | ||
|
|
cd126bb1c7 | ||
|
|
ddbfb91260 | ||
|
|
caac40c4b1 | ||
|
|
2632feb3e8 | ||
|
|
778447c1fd | ||
|
|
5a1f555703 | ||
|
|
2ed18d6588 | ||
|
|
c494b8e9f9 | ||
|
|
354fdd6791 | ||
|
|
f48e0348ad | ||
|
|
23eaddf87c | ||
|
|
a90dfb2805 | ||
|
|
78d168050a | ||
|
|
b92d55b9a0 | ||
|
|
907f6193b5 | ||
|
|
6f34f2e6a6 |
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: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
- 🚀 **Skp intro / credits support**
|
||||||
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
|
|||||||
13
app.json
13
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.12.0",
|
"version": "0.16.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio"],
|
"UIBackgroundModes": ["audio", "fetch"],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 36,
|
"versionCode": 42,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon.png"
|
"foregroundImage": "./assets/images/icon.png"
|
||||||
},
|
},
|
||||||
@@ -43,11 +43,6 @@
|
|||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
|
||||||
"bundler": "metro",
|
|
||||||
"output": "static",
|
|
||||||
"favicon": "./assets/images/favicon.png"
|
|
||||||
},
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
@@ -82,7 +77,7 @@
|
|||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "14.0"
|
"deploymentTarget": "15.6"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"android": {
|
"android": {
|
||||||
|
|||||||
@@ -18,18 +18,6 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
marginRight: Platform.OS === "android" ? 17 : 0,
|
|
||||||
}}
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Feather name="download" color={"white"} size={22} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
<Chromecast />
|
||||||
@@ -37,10 +25,9 @@ export default function IndexLayout() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
|
className="p-2 "
|
||||||
>
|
>
|
||||||
<View className="h-10 aspect-square flex items-center justify-center rounded">
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
@@ -61,6 +48,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,32 +1,23 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
import { Loader } from "@/components/Loader";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
const downloads: React.FC = () => {
|
||||||
const [process, setProcess] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
|
const { removeProcess, downloadedFiles } = useDownload();
|
||||||
|
|
||||||
const { data: downloadedFiles, isLoading } = useQuery({
|
const [settings] = useSettings();
|
||||||
queryKey: ["downloaded_files", process?.item.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
|
||||||
) as BaseItemDto[],
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const movies = useMemo(
|
const movies = useMemo(
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||||
@@ -43,27 +34,8 @@ const downloads: React.FC = () => {
|
|||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const eta = useMemo(() => {
|
|
||||||
const length = process?.item?.RunTimeTicks || 0;
|
|
||||||
|
|
||||||
if (!process?.speed || !process?.progress) return "";
|
|
||||||
|
|
||||||
const timeLeft =
|
|
||||||
(length - length * (process.progress / 100)) / process.speed;
|
|
||||||
|
|
||||||
return formatNumber(timeLeft / 10000);
|
|
||||||
}, [process]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -72,121 +44,80 @@ const downloads: React.FC = () => {
|
|||||||
paddingBottom: 100,
|
paddingBottom: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="px-4 py-4">
|
<View className="py-4">
|
||||||
<View className="mb-4 flex flex-col space-y-4">
|
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||||
<View>
|
{settings?.downloadMethod === "remux" && (
|
||||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<View className="flex flex-col space-y-2">
|
<Text className="text-lg font-bold">Queue</Text>
|
||||||
{queue.map((q) => (
|
<Text className="text-xs opacity-70 text-red-600">
|
||||||
<TouchableOpacity
|
Queue and downloads will be lost on app restart
|
||||||
onPress={() =>
|
</Text>
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
<View className="flex flex-col space-y-2 mt-2">
|
||||||
}
|
{queue.map((q) => (
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() =>
|
||||||
setQueue((prev) => prev.filter((i) => i.id !== q.id));
|
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||||
}}
|
}
|
||||||
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
|
||||||
{process?.item ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push(`/(auth)/items/page?id=${process.item.Id}`)
|
|
||||||
}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{process.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{process.item.Type}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.progress.toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.speed?.toFixed(2)}x
|
|
||||||
</Text>
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-xs">ETA {eta}</Text>
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
|
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<TouchableOpacity
|
||||||
</View>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
removeProcess(q.id);
|
||||||
onPress={() => {
|
setQueue((prev) => {
|
||||||
FFmpegKit.cancel();
|
if (!prev) return [];
|
||||||
setProcess(null);
|
return [...prev.filter((i) => i.id !== q.id)];
|
||||||
}}
|
});
|
||||||
>
|
}}
|
||||||
<Ionicons name="close" size={24} color="red" />
|
>
|
||||||
</TouchableOpacity>
|
<Ionicons name="close" size={24} color="red" />
|
||||||
<View
|
</TouchableOpacity>
|
||||||
className={`
|
</TouchableOpacity>
|
||||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
))}
|
||||||
`}
|
</View>
|
||||||
style={{
|
|
||||||
width: process.progress
|
{queue.length === 0 && (
|
||||||
? `${Math.max(5, process.progress)}%`
|
<Text className="opacity-50">No items in queue</Text>
|
||||||
: "5%",
|
)}
|
||||||
}}
|
</View>
|
||||||
></View>
|
)}
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
<ActiveDownloads />
|
||||||
<Text className="opacity-50">No active downloads</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<View className="flex flex-row items-center justify-between mb-2">
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
<Text className="text-2xl font-bold">Movies</Text>
|
<Text className="text-lg font-bold">Movies</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{movies?.map((item: BaseItemDto) => (
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
<View className="px-4 flex flex-row">
|
||||||
<MovieCard item={item} />
|
{movies?.map((item: BaseItemDto) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||||
|
<MovieCard item={item} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
))}
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||||
))}
|
))}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className="flex px-4">
|
||||||
|
<Text className="opacity-50">No downloaded items</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default downloads;
|
export default downloads;
|
||||||
|
|
||||||
/*
|
|
||||||
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
|
|
||||||
* @param {number} num - The number to format
|
|
||||||
*
|
|
||||||
* @returns {string} - The formatted string
|
|
||||||
*/
|
|
||||||
const formatNumber = (num: number) => {
|
|
||||||
const minutes = Math.floor(num / 60000);
|
|
||||||
const seconds = ((num % 60000) / 1000).toFixed(0);
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ 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 { Ionicons } from "@expo/vector-icons";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getItemsApi,
|
getItemsApi,
|
||||||
getSuggestionsApi,
|
getSuggestionsApi,
|
||||||
@@ -17,33 +20,30 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { QueryFunction, 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 {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
type BaseSection = {
|
type ScrollingCollectionListSection = {
|
||||||
title: string;
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = BaseSection & {
|
|
||||||
type: "ScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
queryFn: () => Promise<BaseItemDto[]>;
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSection = BaseSection & {
|
type MediaListSection = {
|
||||||
type: "MediaListSection";
|
type: "MediaListSection";
|
||||||
queryFn: () => Promise<BaseItemDto>;
|
queryKey: (string | undefined)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
@@ -128,34 +128,67 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const movieCollectionId = useMemo(() => {
|
const collections = useMemo(() => {
|
||||||
return userViews?.find((c) => c.CollectionType === "movies")?.Id;
|
const allow = ["movies", "tvshows"];
|
||||||
}, [userViews]);
|
return (
|
||||||
|
userViews?.filter(
|
||||||
const tvShowCollectionId = useMemo(() => {
|
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
||||||
return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
|
) || []
|
||||||
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await queryClient.refetchQueries({ queryKey: ["userViews"] });
|
await queryClient.invalidateQueries();
|
||||||
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
|
||||||
await queryClient.refetchQueries({
|
|
||||||
queryKey: ["sf_promoted"],
|
|
||||||
});
|
|
||||||
await queryClient.refetchQueries({
|
|
||||||
queryKey: ["sf_carousel"],
|
|
||||||
});
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [queryClient, user?.Id]);
|
}, [queryClient, user?.Id]);
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined
|
||||||
|
): ScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 50,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || []
|
||||||
|
);
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
}),
|
||||||
|
[api, user?.Id]
|
||||||
|
);
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
|
const title = "Recently Added in " + c.Name;
|
||||||
|
const queryKey = ["recentlyAddedIn" + c.CollectionType, user?.Id!, c.Id!];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
{
|
||||||
title: "Continue Watching",
|
title: "Continue Watching",
|
||||||
@@ -185,49 +218,17 @@ export default function index() {
|
|||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
},
|
},
|
||||||
|
...latestMediaViews,
|
||||||
...(mediaListCollections?.map(
|
...(mediaListCollections?.map(
|
||||||
(ml) =>
|
(ml) =>
|
||||||
({
|
({
|
||||||
title: ml.Name || "",
|
title: ml.Name,
|
||||||
queryKey: ["mediaList", ml.Id],
|
queryKey: ["mediaList", ml.Id!],
|
||||||
queryFn: async () => ml,
|
queryFn: async () => ml,
|
||||||
type: "MediaListSection",
|
type: "MediaListSection",
|
||||||
} as MediaListSection)
|
orientation: "vertical",
|
||||||
|
} as Section)
|
||||||
) || []),
|
) || []),
|
||||||
{
|
|
||||||
title: "Recently Added in Movies",
|
|
||||||
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 50,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie"],
|
|
||||||
parentId: movieCollectionId,
|
|
||||||
})
|
|
||||||
).data || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Recently Added in TV-Shows",
|
|
||||||
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 50,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Series"],
|
|
||||||
parentId: tvShowCollectionId,
|
|
||||||
})
|
|
||||||
).data || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Suggested Movies",
|
title: "Suggested Movies",
|
||||||
queryKey: ["suggestedMovies", user?.Id],
|
queryKey: ["suggestedMovies", user?.Id],
|
||||||
@@ -265,13 +266,7 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return ss;
|
return ss;
|
||||||
}, [
|
}, [api, user?.Id, collections, mediaListCollections]);
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
movieCollectionId,
|
|
||||||
tvShowCollectionId,
|
|
||||||
mediaListCollections,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isConnected === false) {
|
if (isConnected === false) {
|
||||||
return (
|
return (
|
||||||
@@ -317,7 +312,7 @@ export default function index() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (e1 || e2)
|
if (e1 || e2 || !api)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||||
@@ -341,39 +336,37 @@ export default function index() {
|
|||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
|
key={"home"}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
className="flex flex-col space-y-4 mb-20"
|
||||||
>
|
>
|
||||||
<View
|
<LargeMovieCarousel />
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
className="flex flex-col pt-4 pb-24 gap-y-4"
|
|
||||||
>
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
if (section.type === "ScrollingCollectionList") {
|
if (section.type === "ScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
key={index}
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (section.type === "MediaListSection") {
|
} else if (section.type === "MediaListSection") {
|
||||||
return (
|
return (
|
||||||
<MediaListSection
|
<MediaListSection
|
||||||
key={index}
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
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 { 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 { 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();
|
||||||
const { deleteAllFiles } = useFiles();
|
const { deleteAllFiles } = useDownload();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -26,6 +28,35 @@ 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,
|
||||||
|
});
|
||||||
|
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,43 +66,68 @@ export default function settings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
|
{/* <Button
|
||||||
|
onPress={() => {
|
||||||
|
registerBackgroundFetchAsync();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
registerBackgroundFetchAsync
|
||||||
|
</Button> */}
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl 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} />
|
||||||
|
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
|
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||||
|
Authorize
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View>
|
||||||
<Button color="black" onPress={logout}>
|
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
||||||
Log out
|
<View className="flex flex-col space-y-2">
|
||||||
</Button>
|
<Button color="black" onPress={logout}>
|
||||||
<Button
|
Log out
|
||||||
color="red"
|
</Button>
|
||||||
onPress={async () => {
|
<Button
|
||||||
await deleteAllFiles();
|
color="red"
|
||||||
Haptics.notificationAsync(
|
onPress={async () => {
|
||||||
Haptics.NotificationFeedbackType.Success
|
try {
|
||||||
);
|
await deleteAllFiles();
|
||||||
}}
|
Haptics.notificationAsync(
|
||||||
>
|
Haptics.NotificationFeedbackType.Success
|
||||||
Delete all downloaded files
|
);
|
||||||
</Button>
|
} catch (e) {
|
||||||
<Button
|
Haptics.notificationAsync(
|
||||||
color="red"
|
Haptics.NotificationFeedbackType.Error
|
||||||
onPress={async () => {
|
);
|
||||||
await clearLogs();
|
toast.error("Error deleting files");
|
||||||
Haptics.notificationAsync(
|
}
|
||||||
Haptics.NotificationFeedbackType.Success
|
}}
|
||||||
);
|
>
|
||||||
}}
|
Delete all downloaded files
|
||||||
>
|
</Button>
|
||||||
Delete all logs
|
<Button
|
||||||
</Button>
|
color="red"
|
||||||
|
onPress={async () => {
|
||||||
|
await clearLogs();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete all logs
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ import { useAtom } from "jotai";
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
const { collectionId } = searchParams as { collectionId: string };
|
||||||
@@ -169,7 +167,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<MemoizedTouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -193,7 +191,7 @@ const page: React.FC = () => {
|
|||||||
{/* <MoviePoster item={item} /> */}
|
{/* <MoviePoster item={item} /> */}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</MemoizedTouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import React from "react";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
@@ -20,8 +21,6 @@ const page: React.FC = () => {
|
|||||||
seasonIndex: string;
|
seasonIndex: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("seasonIndex", seasonIndex);
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useFocusEffect, useLocalSearchParams } 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, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||||
@@ -65,13 +69,11 @@ const Page = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
console.log("getSortOrderPreference ~", sop, libraryId);
|
|
||||||
_setSortOrder([sop]);
|
_setSortOrder([sop]);
|
||||||
} else {
|
} else {
|
||||||
_setSortOrder([SortOrderOption.Ascending]);
|
_setSortOrder([SortOrderOption.Ascending]);
|
||||||
}
|
}
|
||||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||||
console.log("getSortByPreference ~", obp, libraryId);
|
|
||||||
if (obp) {
|
if (obp) {
|
||||||
_setSortBy([obp]);
|
_setSortBy([obp]);
|
||||||
} else {
|
} else {
|
||||||
@@ -83,7 +85,6 @@ const Page = () => {
|
|||||||
(sortBy: SortByOption[]) => {
|
(sortBy: SortByOption[]) => {
|
||||||
const sop = getSortByPreference(libraryId, sortByPreference);
|
const sop = getSortByPreference(libraryId, sortByPreference);
|
||||||
if (sortBy[0] !== sop) {
|
if (sortBy[0] !== sop) {
|
||||||
console.log("setSortByPreference ~", sortBy[0], libraryId);
|
|
||||||
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
|
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
|
||||||
}
|
}
|
||||||
_setSortBy(sortBy);
|
_setSortBy(sortBy);
|
||||||
@@ -95,7 +96,6 @@ const Page = () => {
|
|||||||
(sortOrder: SortOrderOption[]) => {
|
(sortOrder: SortOrderOption[]) => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sortOrder[0] !== sop) {
|
if (sortOrder[0] !== sop) {
|
||||||
console.log("setSortOrderPreference ~", sortOrder[0], libraryId);
|
|
||||||
setOderByPreference({
|
setOderByPreference({
|
||||||
...sortOrderPreference,
|
...sortOrderPreference,
|
||||||
[libraryId]: sortOrder[0],
|
[libraryId]: sortOrder[0],
|
||||||
@@ -128,6 +128,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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import AlbumCover from "@/components/posters/AlbumCover";
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
@@ -225,9 +226,13 @@ export default function search() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4 pb-32">
|
<View className="flex flex-col pt-2">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
<View className="mb-4 px-4">
|
<View className="mb-4 px-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -250,165 +255,125 @@ export default function search() {
|
|||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header="Movies"
|
header="Movies"
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
key={item.Id}
|
||||||
renderItem={(item) => (
|
className="flex flex-col w-28 mr-2"
|
||||||
<TouchableItemRouter
|
item={item}
|
||||||
key={item.Id}
|
>
|
||||||
className="flex flex-col w-28"
|
<MoviePoster item={item} key={item.Id} />
|
||||||
item={item}
|
<Text numberOfLines={2} className="mt-2">
|
||||||
>
|
{item.Name}
|
||||||
<MoviePoster item={item} key={item.Id} />
|
</Text>
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.Name}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="opacity-50 text-xs">
|
</TouchableItemRouter>
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={series?.map((m) => m.Id!)}
|
ids={series?.map((m) => m.Id!)}
|
||||||
header="Series"
|
header="Series"
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
key={item.Id}
|
||||||
renderItem={(item) => (
|
item={item}
|
||||||
<TouchableOpacity
|
className="flex flex-col w-28"
|
||||||
key={item.Id}
|
>
|
||||||
onPress={() => router.push(`/series/${item.Id}`)}
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
className="flex flex-col w-28"
|
<Text numberOfLines={2} className="mt-2">
|
||||||
>
|
{item.Name}
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
</Text>
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.Name}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="opacity-50 text-xs">
|
</TouchableItemRouter>
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
header="Episodes"
|
header="Episodes"
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
item={item}
|
||||||
renderItem={(item) => (
|
key={item.Id}
|
||||||
<TouchableOpacity
|
className="flex flex-col w-44"
|
||||||
key={item.Id}
|
>
|
||||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
<ContinueWatchingPoster item={item} />
|
||||||
className="flex flex-col w-44"
|
<ItemCardText item={item} />
|
||||||
>
|
</TouchableItemRouter>
|
||||||
<ContinueWatchingPoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={collections?.map((m) => m.Id!)}
|
ids={collections?.map((m) => m.Id!)}
|
||||||
header="Collections"
|
header="Collections"
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
key={item.Id}
|
||||||
renderItem={(item) => (
|
item={item}
|
||||||
<TouchableOpacity
|
className="flex flex-col w-28"
|
||||||
key={item.Id}
|
>
|
||||||
className="flex flex-col w-28"
|
<MoviePoster item={item} key={item.Id} />
|
||||||
onPress={() => router.push(`/collections/${item.Id}`)}
|
<Text numberOfLines={2} className="mt-2">
|
||||||
>
|
{item.Name}
|
||||||
<MoviePoster item={item} key={item.Id} />
|
</Text>
|
||||||
<Text numberOfLines={2} className="mt-2">
|
</TouchableItemRouter>
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={actors?.map((m) => m.Id!)}
|
ids={actors?.map((m) => m.Id!)}
|
||||||
header="Actors"
|
header="Actors"
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
item={item}
|
||||||
renderItem={(item) => (
|
key={item.Id}
|
||||||
<TouchableItemRouter
|
className="flex flex-col w-28"
|
||||||
item={item}
|
>
|
||||||
key={item.Id}
|
<MoviePoster item={item} />
|
||||||
className="flex flex-col w-28"
|
<ItemCardText item={item} />
|
||||||
>
|
</TouchableItemRouter>
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={artists?.map((m) => m.Id!)}
|
ids={artists?.map((m) => m.Id!)}
|
||||||
header="Artists"
|
header="Artists"
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
item={item}
|
||||||
renderItem={(item) => (
|
key={item.Id}
|
||||||
<TouchableItemRouter
|
className="flex flex-col w-28"
|
||||||
item={item}
|
>
|
||||||
key={item.Id}
|
<AlbumCover id={item.Id} />
|
||||||
className="flex flex-col w-28"
|
<ItemCardText item={item} />
|
||||||
>
|
</TouchableItemRouter>
|
||||||
<AlbumCover id={item.Id} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={albums?.map((m) => m.Id!)}
|
ids={albums?.map((m) => m.Id!)}
|
||||||
header="Albums"
|
header="Albums"
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
item={item}
|
||||||
renderItem={(item) => (
|
key={item.Id}
|
||||||
<TouchableItemRouter
|
className="flex flex-col w-28"
|
||||||
item={item}
|
>
|
||||||
key={item.Id}
|
<AlbumCover id={item.Id} />
|
||||||
className="flex flex-col w-28"
|
<ItemCardText item={item} />
|
||||||
>
|
</TouchableItemRouter>
|
||||||
<AlbumCover id={item.Id} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={songs?.map((m) => m.Id!)}
|
ids={songs?.map((m) => m.Id!)}
|
||||||
header="Songs"
|
header="Songs"
|
||||||
renderItem={(data) => (
|
renderItem={(item) => (
|
||||||
<HorizontalScroll
|
<TouchableItemRouter
|
||||||
data={data}
|
item={item}
|
||||||
renderItem={(item) => (
|
key={item.Id}
|
||||||
<TouchableItemRouter
|
className="flex flex-col w-28"
|
||||||
item={item}
|
>
|
||||||
key={item.Id}
|
<AlbumCover id={item.AlbumId} />
|
||||||
className="flex flex-col w-28"
|
<ItemCardText item={item} />
|
||||||
>
|
</TouchableItemRouter>
|
||||||
<AlbumCover id={item.AlbumId} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -445,7 +410,7 @@ export default function search() {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
ids?: string[] | null;
|
ids?: string[] | null;
|
||||||
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
renderItem: (item: BaseItemDto) => React.ReactNode;
|
||||||
header?: string;
|
header?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -483,8 +448,14 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
|
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
||||||
{renderItem(data)}
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className="px-4 mb-2"
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{data.map((item) => renderItem(item))}
|
||||||
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
14
app/(auth)/play-music.tsx
Normal file
14
app/(auth)/play-music.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
return (
|
||||||
|
<View className="">
|
||||||
|
<StatusBar hidden={false} />
|
||||||
|
<FullScreenMusicPlayer />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
app/(auth)/play.tsx
Normal file
48
app/(auth)/play.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform, View, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.autoRotate) {
|
||||||
|
// Don't need to do anything
|
||||||
|
} else if (settings?.defaultVideoOrientation) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setVisibilityAsync("hidden");
|
||||||
|
NavigationBar.setBehaviorAsync("overlay-swipe");
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (settings?.autoRotate) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setVisibilityAsync("visible");
|
||||||
|
NavigationBar.setBehaviorAsync("inset-swipe");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="">
|
||||||
|
<StatusBar hidden />
|
||||||
|
<FullScreenVideoPlayer />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
app/_layout.tsx
333
app/_layout.tsx
@@ -1,27 +1,217 @@
|
|||||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
import {
|
||||||
|
getOrSetDeviceId,
|
||||||
|
getTokenFromStoraage,
|
||||||
|
JellyfinProvider,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
|
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
checkForExistingDownloads,
|
||||||
|
completeHandler,
|
||||||
|
download,
|
||||||
|
} from "@kesha-antonov/react-native-background-downloader";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useFonts } from "expo-font";
|
import { useFonts } from "expo-font";
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import * as Linking from "expo-linking";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import { router, Stack } 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 * as TaskManager from "expo-task-manager";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
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 { Toaster } from "sonner-native";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function useNotificationObserver() {
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
function redirect(notification: Notifications.Notification) {
|
||||||
|
const url = notification.request.content.data?.url;
|
||||||
|
if (url) {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
|
if (!isMounted || !response?.notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirect(response?.notification);
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
|
(response) => {
|
||||||
|
redirect(response.notification);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const settingsData = await AsyncStorage.getItem("settings");
|
||||||
|
|
||||||
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
|
if (!settings?.autoDownload || !url)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
|
const token = await getTokenFromStoraage();
|
||||||
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
if (!token || !deviceId || !baseDirectory)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobs = await getAllJobsByDeviceId({
|
||||||
|
deviceId,
|
||||||
|
authHeader: token,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
|
|
||||||
|
for (let job of jobs) {
|
||||||
|
if (job.status === "completed") {
|
||||||
|
const downloadUrl = url + "download/" + job.id;
|
||||||
|
console.log({
|
||||||
|
token,
|
||||||
|
deviceId,
|
||||||
|
baseDirectory,
|
||||||
|
url,
|
||||||
|
downloadUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = await checkForExistingDownloads();
|
||||||
|
|
||||||
|
if (tasks.find((task) => task.id === job.id)) {
|
||||||
|
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
download({
|
||||||
|
id: job.id,
|
||||||
|
url: url + "download/" + job.id,
|
||||||
|
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.begin(() => {
|
||||||
|
console.log("TaskManager ~ Download started: ", job.id);
|
||||||
|
})
|
||||||
|
.done(() => {
|
||||||
|
console.log("TaskManager ~ Download completed: ", job.id);
|
||||||
|
saveDownloadedItemInfo(job.item);
|
||||||
|
completeHandler(job.id);
|
||||||
|
cancelJobById({
|
||||||
|
authHeader: token,
|
||||||
|
id: job.id,
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download completed",
|
||||||
|
data: {
|
||||||
|
url: `/downloads`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.error((error) => {
|
||||||
|
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||||
|
completeHandler(job.id);
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download failed",
|
||||||
|
data: {
|
||||||
|
url: `/downloads`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
|
|
||||||
|
// Be sure to return the successful result type!
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkAndRequestPermissions = async () => {
|
||||||
|
try {
|
||||||
|
const hasAskedBefore = await AsyncStorage.getItem(
|
||||||
|
"hasAskedForNotificationPermission"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAskedBefore !== "true") {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
|
||||||
|
if (status === "granted") {
|
||||||
|
console.log("Notification permissions granted.");
|
||||||
|
} else {
|
||||||
|
console.log("Notification permissions denied.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
|
||||||
|
} else {
|
||||||
|
console.log("Already asked for notification permissions before.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking/requesting notification permissions:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
@@ -49,12 +239,13 @@ function Layout() {
|
|||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||||
|
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
|
useNotificationObserver();
|
||||||
|
|
||||||
const queryClientRef = useRef<QueryClient>(
|
const queryClientRef = useRef<QueryClient>(
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 60 * 1000,
|
staleTime: 0,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
@@ -64,6 +255,10 @@ function Layout() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAndRequestPermissions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings?.autoRotate === true)
|
if (settings?.autoRotate === true)
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||||
@@ -73,10 +268,28 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
const appState = useRef(AppState.currentState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
if (
|
||||||
|
appState.current.match(/inactive|background/) &&
|
||||||
|
nextAppState === "active"
|
||||||
|
) {
|
||||||
|
checkForExistingDownloads();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
checkForExistingDownloads();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
(event) => {
|
(event) => {
|
||||||
console.log(event.orientationInfo.orientation);
|
|
||||||
setOrientation(event.orientationInfo.orientation);
|
setOrientation(event.orientationInfo.orientation);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -100,34 +313,90 @@ function Layout() {
|
|||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<JobQueueProvider>
|
<JobQueueProvider>
|
||||||
<ActionSheetProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<ActionSheetProvider>
|
||||||
<JellyfinProvider>
|
<BottomSheetModalProvider>
|
||||||
<PlaybackProvider>
|
<JellyfinProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<PlaybackProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<Stack initialRouteName="/home">
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack.Screen
|
<Stack
|
||||||
name="(auth)/(tabs)"
|
initialRouteName="/home"
|
||||||
options={{
|
screenOptions={{
|
||||||
headerShown: false,
|
autoHideHomeIndicator: true,
|
||||||
title: "",
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/(tabs)"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/play"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/play-music"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="login"
|
||||||
|
options={{ headerShown: false, title: "Login" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="+not-found" />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
</ThemeProvider>
|
||||||
name="login"
|
</PlaybackProvider>
|
||||||
options={{ headerShown: false, title: "Login" }}
|
</JellyfinProvider>
|
||||||
/>
|
</BottomSheetModalProvider>
|
||||||
<Stack.Screen name="+not-found" />
|
</ActionSheetProvider>
|
||||||
</Stack>
|
</DownloadProvider>
|
||||||
<CurrentlyPlayingBar />
|
|
||||||
</ThemeProvider>
|
|
||||||
</PlaybackProvider>
|
|
||||||
</JellyfinProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</ActionSheetProvider>
|
|
||||||
</JobQueueProvider>
|
</JobQueueProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
|
try {
|
||||||
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
|
let items: BaseItemDto[] = downloadedItems
|
||||||
|
? JSON.parse(downloadedItems)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||||
|
if (existingItemIndex !== -1) {
|
||||||
|
items[existingItemIndex] = item;
|
||||||
|
} else {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save downloaded item information:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
112
app/login.tsx
112
app/login.tsx
@@ -3,6 +3,8 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
@@ -33,6 +35,7 @@ const Login: React.FC = () => {
|
|||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
@@ -44,6 +47,8 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// we might re-use the checkUrl function here to check the url as well
|
||||||
|
// however, I don't think it should be necessary for now
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
@@ -79,12 +84,93 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConnect = (url: string) => {
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
if (!url.startsWith("http")) {
|
|
||||||
Alert.alert("Error", "URL needs to start with http or https.");
|
/**
|
||||||
|
* Checks the availability and validity of a Jellyfin server URL.
|
||||||
|
*
|
||||||
|
* This function attempts to connect to a Jellyfin server using the provided URL.
|
||||||
|
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
|
||||||
|
*
|
||||||
|
* @param {string} url - The base URL of the Jellyfin server to check.
|
||||||
|
* @returns {Promise<string | undefined>} A Promise that resolves to:
|
||||||
|
* - The full URL (including protocol) if a valid Jellyfin server is found.
|
||||||
|
* - undefined if no valid server is found at the given URL.
|
||||||
|
*
|
||||||
|
* Side effects:
|
||||||
|
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||||
|
* - Logs errors and timeout information to the console.
|
||||||
|
*/
|
||||||
|
async function checkUrl(url: string) {
|
||||||
|
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
|
const protocols = ["https://", "http://"];
|
||||||
|
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const protocol of protocols) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
|
||||||
|
mode: "cors",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
setServerName(data.ServerName || "");
|
||||||
|
return `${protocol}${url}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as Error;
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
console.log(`Request to ${protocol}${url} timed out`);
|
||||||
|
} else {
|
||||||
|
console.error(`Error checking ${protocol}${url}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
setLoadingServerCheck(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the connection attempt to a Jellyfin server.
|
||||||
|
*
|
||||||
|
* This function trims the input URL, checks its validity using the `checkUrl` function,
|
||||||
|
* and sets the server address if a valid connection is established.
|
||||||
|
*
|
||||||
|
* @param {string} url - The URL of the Jellyfin server to connect to.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*
|
||||||
|
* Side effects:
|
||||||
|
* - Calls `checkUrl` to validate the server URL.
|
||||||
|
* - Shows an alert if the connection fails.
|
||||||
|
* - Sets the server address using `setServer` if the connection is successful.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const handleConnect = async (url: string) => {
|
||||||
|
url = url.trim();
|
||||||
|
|
||||||
|
const result = await checkUrl(
|
||||||
|
url.startsWith("http") ? new URL(url).host : url
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === undefined) {
|
||||||
|
Alert.alert(
|
||||||
|
"Connection failed",
|
||||||
|
"Could not connect to the server. Please check the URL and your network connection."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setServer({ address: url.trim() });
|
|
||||||
|
setServer({ address: result });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
@@ -113,7 +199,9 @@ const Login: React.FC = () => {
|
|||||||
<View></View>
|
<View></View>
|
||||||
<View>
|
<View>
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
<Text className="text-3xl font-bold mb-1">
|
||||||
|
{serverName || "Streamyfin"}
|
||||||
|
</Text>
|
||||||
<Text className="text-neutral-500 mb-2">
|
<Text className="text-neutral-500 mb-2">
|
||||||
Server: {api.basePath}
|
Server: {api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -121,7 +209,6 @@ const Login: React.FC = () => {
|
|||||||
color="black"
|
color="black"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
setServerURL("");
|
|
||||||
}}
|
}}
|
||||||
justify="between"
|
justify="between"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
@@ -138,9 +225,6 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold">Log in</Text>
|
<Text className="text-2xl font-bold">Log in</Text>
|
||||||
<Text className="text-neutral-500">
|
|
||||||
Log in to any user account
|
|
||||||
</Text>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -218,11 +302,13 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Text className="opacity-30">
|
|
||||||
Server URL requires http or https
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
<Button
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck}
|
||||||
|
onPress={async () => await handleConnect(serverURL)}
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
const index = source.DefaultAudioStreamIndex;
|
const index = source.DefaultAudioStreamIndex;
|
||||||
if (index !== undefined && index !== null) {
|
if (index !== undefined && index !== null) {
|
||||||
console.log("DefaultAudioStreamIndex", index);
|
|
||||||
onChange(index);
|
onChange(index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import { useAtom } from "jotai";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
width?: number;
|
|
||||||
useEpisodePoster?: boolean;
|
useEpisodePoster?: boolean;
|
||||||
|
size?: "small" | "normal";
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
width = 176,
|
|
||||||
useEpisodePoster = false,
|
useEpisodePoster = false,
|
||||||
|
size = "normal",
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
@@ -47,20 +48,15 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return (
|
return (
|
||||||
<View
|
<View className="aspect-video border border-neutral-800 w-44"></View>
|
||||||
className="aspect-video border border-neutral-800"
|
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
className={`
|
||||||
width,
|
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
|
||||||
}}
|
${size === "small" ? "w-32" : "w-44"}
|
||||||
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
|
`}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
@@ -76,10 +72,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
style={{
|
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||||
width: `100%`,
|
|
||||||
}}
|
|
||||||
className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
|
||||||
></View>
|
></View>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import ios from "@/utils/profiles/ios";
|
import ios from "@/utils/profiles/ios";
|
||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
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";
|
||||||
@@ -31,6 +29,7 @@ import { Loader } from "./Loader";
|
|||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -39,10 +38,10 @@ interface DownloadProps extends ViewProps {
|
|||||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [process] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
const { processes, startBackgroundDownload } = useDownload();
|
||||||
|
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] =
|
const [selectedMediaSource, setSelectedMediaSource] =
|
||||||
useState<MediaSourceInfo | null>(null);
|
useState<MediaSourceInfo | null>(null);
|
||||||
@@ -67,9 +66,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||||
console.log("handleSheetChanges", index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
@@ -115,6 +112,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let url: string | undefined = undefined;
|
let url: string | undefined = undefined;
|
||||||
|
let fileExtension: string | undefined | null = "mp4";
|
||||||
|
|
||||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
||||||
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
||||||
@@ -126,7 +124,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!");
|
||||||
@@ -149,40 +146,40 @@ 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}`;
|
||||||
|
fileExtension = mediaSource.TranscodingContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!url) throw new Error("No url");
|
if (!url) throw new Error("No url");
|
||||||
|
if (!fileExtension) throw new Error("No file extension");
|
||||||
|
|
||||||
return await startRemuxing(url);
|
if (settings?.downloadMethod === "optimized") {
|
||||||
|
return await startBackgroundDownload(url, item, fileExtension);
|
||||||
|
} else {
|
||||||
|
return await startRemuxing(url);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
startRemuxing,
|
startBackgroundDownload,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
selectedMediaSource,
|
selectedMediaSource,
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
maxBitrate,
|
maxBitrate,
|
||||||
|
settings?.downloadMethod,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if item is downloaded
|
* Check if item is downloaded
|
||||||
*/
|
*/
|
||||||
const { data: downloaded, isFetching } = useQuery({
|
const { downloadedFiles } = useDownload();
|
||||||
queryKey: ["downloaded", item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!item.Id) return false;
|
|
||||||
|
|
||||||
const data: BaseItemDto[] = JSON.parse(
|
const isDownloaded = useMemo(() => {
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
if (!downloadedFiles) return false;
|
||||||
);
|
|
||||||
|
|
||||||
return data.some((d) => d.Id === item.Id);
|
return downloadedFiles.some((file) => file.Id === item.Id);
|
||||||
},
|
}, [downloadedFiles, item.Id]);
|
||||||
enabled: !!item.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -195,14 +192,18 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const process = useMemo(() => {
|
||||||
|
if (!processes) return null;
|
||||||
|
|
||||||
|
return processes.find((process) => process?.item?.Id === item.Id);
|
||||||
|
}, [processes, item.Id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
{process && process?.item.Id === item.Id ? (
|
||||||
<Loader />
|
|
||||||
) : process && process?.item.Id === item.Id ? (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/downloads");
|
router.push("/downloads");
|
||||||
@@ -230,7 +231,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
>
|
>
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
<Ionicons name="hourglass" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : downloaded ? (
|
) : isDownloaded ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/downloads");
|
router.push("/downloads");
|
||||||
@@ -290,25 +291,36 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
className="mt-auto"
|
className="mt-auto"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (userCanDownload === true) {
|
if (userCanDownload === true) {
|
||||||
|
if (!item.Id) {
|
||||||
|
throw new Error("No item id");
|
||||||
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
queueActions.enqueue(queue, setQueue, {
|
if (settings?.downloadMethod === "remux") {
|
||||||
id: item.Id!,
|
queueActions.enqueue(queue, setQueue, {
|
||||||
execute: async () => {
|
id: item.Id,
|
||||||
await initiateDownload();
|
execute: async () => {
|
||||||
},
|
await initiateDownload();
|
||||||
item,
|
},
|
||||||
});
|
item,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
initiateDownload();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
toast.error("You are not allowed to download files.");
|
||||||
"Disabled",
|
|
||||||
"This user is not allowed to download files."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
color="purple"
|
color="purple"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
|
<View className="opacity-70 text-center w-full flex items-center">
|
||||||
|
{settings?.downloadMethod === "optimized" ? (
|
||||||
|
<Text className="text-xs">Using optimized server</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-xs">Using default method</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|||||||
544
components/FullScreenMusicPlayer.tsx
Normal file
544
components/FullScreenMusicPlayer.tsx
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
|
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 orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
BackHandler,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
} 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";
|
||||||
|
|
||||||
|
const windowDimensions = Dimensions.get("window");
|
||||||
|
const screenDimensions = Dimensions.get("screen");
|
||||||
|
|
||||||
|
export const FullScreenMusicPlayer: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentlyPlaying,
|
||||||
|
pauseVideo,
|
||||||
|
playVideo,
|
||||||
|
stopPlayback,
|
||||||
|
setIsPlaying,
|
||||||
|
isPlaying,
|
||||||
|
videoRef,
|
||||||
|
onProgress,
|
||||||
|
setIsBuffering,
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
|
||||||
|
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [isBuffering, setIsBufferingState] = useState(true);
|
||||||
|
|
||||||
|
// Seconds
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [remainingTime, setRemainingTime] = useState(0);
|
||||||
|
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
|
||||||
|
|
||||||
|
const [dimensions, setDimensions] = useState({
|
||||||
|
window: windowDimensions,
|
||||||
|
screen: screenDimensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = Dimensions.addEventListener(
|
||||||
|
"change",
|
||||||
|
({ window, screen }) => {
|
||||||
|
setDimensions({ window, screen });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => subscription?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const from = useMemo(() => segments[2], [segments]);
|
||||||
|
|
||||||
|
const updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
const current = ticksToSeconds(currentProgress);
|
||||||
|
const remaining = ticksToSeconds(maxValue - current);
|
||||||
|
|
||||||
|
setCurrentTime(current);
|
||||||
|
setRemainingTime(remaining);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
|
currentlyPlaying?.item.Id,
|
||||||
|
currentTime,
|
||||||
|
videoRef
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
|
currentlyPlaying?.item.Id,
|
||||||
|
currentTime,
|
||||||
|
videoRef
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
progress: progress.value,
|
||||||
|
max: max.value,
|
||||||
|
isSeeking: isSeeking.value,
|
||||||
|
}),
|
||||||
|
(result) => {
|
||||||
|
if (result.isSeeking === false) {
|
||||||
|
runOnJS(updateTimes)(result.progress, result.max);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateTimes]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const backAction = () => {
|
||||||
|
if (currentlyPlaying) {
|
||||||
|
Alert.alert("Hold on!", "Are you sure you want to exit?", [
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
onPress: () => null,
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Yes",
|
||||||
|
onPress: () => {
|
||||||
|
stopPlayback();
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backHandler = BackHandler.addEventListener(
|
||||||
|
"hardwareBackPress",
|
||||||
|
backAction
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => backHandler.remove();
|
||||||
|
}, [currentlyPlaying, stopPlayback, router]);
|
||||||
|
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!currentlyPlaying?.item || !api) return "";
|
||||||
|
return currentlyPlaying.item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: currentlyPlaying.item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [currentlyPlaying?.item, api]);
|
||||||
|
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!api || !currentlyPlaying || !poster) return null;
|
||||||
|
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
uri: currentlyPlaying.url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
|
||||||
|
title: currentlyPlaying.item?.Name || "Unknown",
|
||||||
|
description: currentlyPlaying.item?.Overview ?? undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: currentlyPlaying.item?.Album ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [currentlyPlaying, api, poster]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentlyPlaying) {
|
||||||
|
progress.value =
|
||||||
|
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
|
||||||
|
max.value = currentlyPlaying.item.RunTimeTicks || 0;
|
||||||
|
setShowControls(true);
|
||||||
|
playVideo();
|
||||||
|
}
|
||||||
|
}, [currentlyPlaying]);
|
||||||
|
|
||||||
|
const toggleControls = () => setShowControls(!showControls);
|
||||||
|
|
||||||
|
const handleVideoProgress = useCallback(
|
||||||
|
(data: OnProgressData) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
progress.value = secondsToTicks(data.currentTime);
|
||||||
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
setIsBufferingState(data.playableDuration === 0);
|
||||||
|
setIsBuffering(data.playableDuration === 0);
|
||||||
|
onProgress(data);
|
||||||
|
},
|
||||||
|
[onProgress, setIsBuffering, isSeeking]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVideoError = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
console.log(e);
|
||||||
|
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
|
||||||
|
Alert.alert("Error", "Cannot play this video file.");
|
||||||
|
setIsPlaying(false);
|
||||||
|
},
|
||||||
|
[setIsPlaying]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlayPause = useCallback(() => {
|
||||||
|
if (isPlaying) pauseVideo();
|
||||||
|
else playVideo();
|
||||||
|
}, [isPlaying, pauseVideo, playVideo]);
|
||||||
|
|
||||||
|
const handleSliderComplete = (value: number) => {
|
||||||
|
progress.value = value;
|
||||||
|
isSeeking.value = false;
|
||||||
|
videoRef.current?.seek(value / 10000000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderChange = (value: number) => {};
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
if (showControls === false) return;
|
||||||
|
isSeeking.value = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleGoToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem || !from) return;
|
||||||
|
const url = itemRouter(previousItem, from);
|
||||||
|
stopPlayback();
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
|
}, [previousItem, from, stopPlayback, router]);
|
||||||
|
|
||||||
|
const handleGoToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem || !from) return;
|
||||||
|
const url = itemRouter(nextItem, from);
|
||||||
|
stopPlayback();
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
|
}, [nextItem, from, stopPlayback, router]);
|
||||||
|
|
||||||
|
if (!currentlyPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: dimensions.window.width,
|
||||||
|
height: dimensions.window.height,
|
||||||
|
backgroundColor: "black",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={toggleControls}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: insets.left,
|
||||||
|
right: insets.right,
|
||||||
|
width: dimensions.window.width - (insets.left + insets.right),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{videoSource && (
|
||||||
|
<>
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
resizeMode={"contain"}
|
||||||
|
onProgress={handleVideoProgress}
|
||||||
|
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
|
||||||
|
onError={handleVideoError}
|
||||||
|
playWhenInactive={true}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View pointerEvents="none" className="p-4">
|
||||||
|
<Image
|
||||||
|
source={poster ? { uri: poster } : undefined}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{(showControls || isBuffering) && (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
position: "absolute",
|
||||||
|
width: dimensions.window.width,
|
||||||
|
height: dimensions.window.height,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className=" bg-black/50 z-0"
|
||||||
|
></View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBuffering && (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSkipButton && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 70,
|
||||||
|
right: insets.right + 16,
|
||||||
|
height: 70,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="z-10"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={skipIntro}
|
||||||
|
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||||
|
>
|
||||||
|
<Text className="text-white">Skip Intro</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSkipCreditButton && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 70,
|
||||||
|
right: insets.right + 16,
|
||||||
|
height: 70,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="z-10"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={skipCredit}
|
||||||
|
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||||
|
>
|
||||||
|
<Text className="text-white">Skip Credits</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showControls && (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
right: insets.right + 16,
|
||||||
|
height: 70,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="flex flex-row items-center space-x-2 z-10"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
stopPlayback();
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 8,
|
||||||
|
left: insets.left + 16,
|
||||||
|
width:
|
||||||
|
dimensions.window.width - insets.left - insets.right - 32,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||||
|
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
|
||||||
|
{currentlyPlaying.item?.Type === "Episode" && (
|
||||||
|
<Text className="opacity-50">
|
||||||
|
{currentlyPlaying.item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Movie" && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Audio" && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.Album}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`flex ${"flex-col-reverse py-4 px-4 rounded-2xl"}
|
||||||
|
items-center bg-neutral-800`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !previousItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleGoToPreviousItem}
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
|
<Ionicons
|
||||||
|
name="refresh-outline"
|
||||||
|
size={26}
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handlePlayPause}>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={30}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<Ionicons name="refresh-outline" size={26} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !nextItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleGoToNextItem}
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`flex flex-col w-full shrink
|
||||||
|
${""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
}}
|
||||||
|
cache={cacheProgress}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
sliderHeight={10}
|
||||||
|
progress={progress}
|
||||||
|
thumbWidth={0}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
/>
|
||||||
|
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{formatTimeString(currentTime)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{formatTimeString(remainingTime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
625
components/FullScreenVideoPlayer.tsx
Normal file
625
components/FullScreenVideoPlayer.tsx
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
|
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 orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
BackHandler,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
} 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";
|
||||||
|
|
||||||
|
const windowDimensions = Dimensions.get("window");
|
||||||
|
const screenDimensions = Dimensions.get("screen");
|
||||||
|
|
||||||
|
export const FullScreenVideoPlayer: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentlyPlaying,
|
||||||
|
pauseVideo,
|
||||||
|
playVideo,
|
||||||
|
stopPlayback,
|
||||||
|
setIsPlaying,
|
||||||
|
isPlaying,
|
||||||
|
videoRef,
|
||||||
|
onProgress,
|
||||||
|
setIsBuffering,
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
|
||||||
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
|
||||||
|
useTrickplay(currentlyPlaying);
|
||||||
|
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [isBuffering, setIsBufferingState] = useState(true);
|
||||||
|
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
|
||||||
|
const [orientation, setOrientation] = useState(
|
||||||
|
ScreenOrientation.OrientationLock.UNKNOWN
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seconds
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [remainingTime, setRemainingTime] = useState(0);
|
||||||
|
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
|
||||||
|
|
||||||
|
const [dimensions, setDimensions] = useState({
|
||||||
|
window: windowDimensions,
|
||||||
|
screen: screenDimensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dimensionsSubscription = Dimensions.addEventListener(
|
||||||
|
"change",
|
||||||
|
({ window, screen }) => {
|
||||||
|
setDimensions({ window, screen });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const orientationSubscription =
|
||||||
|
ScreenOrientation.addOrientationChangeListener((event) => {
|
||||||
|
setOrientation(
|
||||||
|
orientationToOrientationLock(event.orientationInfo.orientation)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
||||||
|
setOrientation(orientationToOrientationLock(orientation));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dimensionsSubscription.remove();
|
||||||
|
orientationSubscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const from = useMemo(() => segments[2], [segments]);
|
||||||
|
|
||||||
|
const updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
const current = ticksToSeconds(currentProgress);
|
||||||
|
const remaining = ticksToSeconds(maxValue - current);
|
||||||
|
|
||||||
|
setCurrentTime(current);
|
||||||
|
setRemainingTime(remaining);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
|
currentlyPlaying?.item.Id,
|
||||||
|
currentTime,
|
||||||
|
videoRef
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
|
currentlyPlaying?.item.Id,
|
||||||
|
currentTime,
|
||||||
|
videoRef
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
progress: progress.value,
|
||||||
|
max: max.value,
|
||||||
|
isSeeking: isSeeking.value,
|
||||||
|
}),
|
||||||
|
(result) => {
|
||||||
|
if (result.isSeeking === false) {
|
||||||
|
runOnJS(updateTimes)(result.progress, result.max);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateTimes]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const backAction = () => {
|
||||||
|
if (currentlyPlaying) {
|
||||||
|
Alert.alert("Hold on!", "Are you sure you want to exit?", [
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
onPress: () => null,
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Yes",
|
||||||
|
onPress: () => {
|
||||||
|
stopPlayback();
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backHandler = BackHandler.addEventListener(
|
||||||
|
"hardwareBackPress",
|
||||||
|
backAction
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => backHandler.remove();
|
||||||
|
}, [currentlyPlaying, stopPlayback, router]);
|
||||||
|
|
||||||
|
const isLandscape = useMemo(() => {
|
||||||
|
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
|
||||||
|
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
|
}, [orientation]);
|
||||||
|
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!currentlyPlaying?.item || !api) return "";
|
||||||
|
return currentlyPlaying.item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: currentlyPlaying.item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [currentlyPlaying?.item, api]);
|
||||||
|
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!api || !currentlyPlaying || !poster) return null;
|
||||||
|
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
uri: currentlyPlaying.url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
|
||||||
|
title: currentlyPlaying.item?.Name || "Unknown",
|
||||||
|
description: currentlyPlaying.item?.Overview ?? undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: currentlyPlaying.item?.Album ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [currentlyPlaying, api, poster]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentlyPlaying) {
|
||||||
|
progress.value =
|
||||||
|
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
|
||||||
|
max.value = currentlyPlaying.item.RunTimeTicks || 0;
|
||||||
|
setShowControls(true);
|
||||||
|
playVideo();
|
||||||
|
}
|
||||||
|
}, [currentlyPlaying]);
|
||||||
|
|
||||||
|
const toggleControls = () => setShowControls(!showControls);
|
||||||
|
|
||||||
|
const handleVideoProgress = useCallback(
|
||||||
|
(data: OnProgressData) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
progress.value = secondsToTicks(data.currentTime);
|
||||||
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
setIsBufferingState(data.playableDuration === 0);
|
||||||
|
setIsBuffering(data.playableDuration === 0);
|
||||||
|
onProgress(data);
|
||||||
|
},
|
||||||
|
[onProgress, setIsBuffering, isSeeking]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVideoError = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
console.log(e);
|
||||||
|
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
|
||||||
|
Alert.alert("Error", "Cannot play this video file.");
|
||||||
|
setIsPlaying(false);
|
||||||
|
},
|
||||||
|
[setIsPlaying]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlayPause = useCallback(() => {
|
||||||
|
if (isPlaying) pauseVideo();
|
||||||
|
else playVideo();
|
||||||
|
}, [isPlaying, pauseVideo, playVideo]);
|
||||||
|
|
||||||
|
const handleSliderComplete = (value: number) => {
|
||||||
|
progress.value = value;
|
||||||
|
isSeeking.value = false;
|
||||||
|
videoRef.current?.seek(value / 10000000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderChange = (value: number) => {
|
||||||
|
calculateTrickplayUrl(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
if (showControls === false) return;
|
||||||
|
isSeeking.value = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleGoToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem || !from) return;
|
||||||
|
const url = itemRouter(previousItem, from);
|
||||||
|
stopPlayback();
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
|
}, [previousItem, from, stopPlayback, router]);
|
||||||
|
|
||||||
|
const handleGoToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem || !from) return;
|
||||||
|
const url = itemRouter(nextItem, from);
|
||||||
|
stopPlayback();
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
|
}, [nextItem, from, stopPlayback, router]);
|
||||||
|
|
||||||
|
const toggleIgnoreSafeArea = useCallback(() => {
|
||||||
|
setIgnoreSafeArea((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!currentlyPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: dimensions.window.width,
|
||||||
|
height: dimensions.window.height,
|
||||||
|
backgroundColor: "black",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={toggleControls}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: ignoreSafeArea ? 0 : insets.left,
|
||||||
|
right: ignoreSafeArea ? 0 : insets.right,
|
||||||
|
width: ignoreSafeArea
|
||||||
|
? dimensions.window.width
|
||||||
|
: dimensions.window.width - (insets.left + insets.right),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{videoSource && (
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
resizeMode={ignoreSafeArea ? "cover" : "contain"}
|
||||||
|
onProgress={handleVideoProgress}
|
||||||
|
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
|
||||||
|
onError={handleVideoError}
|
||||||
|
playWhenInactive={true}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{(showControls || isBuffering) && (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
position: "absolute",
|
||||||
|
width: dimensions.window.width,
|
||||||
|
height: dimensions.window.height,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className=" bg-black/50 z-0"
|
||||||
|
></View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBuffering && (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSkipButton && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
|
||||||
|
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
||||||
|
height: 70,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="z-10"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={skipIntro}
|
||||||
|
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||||
|
>
|
||||||
|
<Text className="text-white">Skip Intro</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSkipCreditButton && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
|
||||||
|
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
||||||
|
height: 70,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="z-10"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={skipCredit}
|
||||||
|
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||||
|
>
|
||||||
|
<Text className="text-white">Skip Credits</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showControls && (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
right: isLandscape ? insets.right + 32 : insets.right + 16,
|
||||||
|
height: 70,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="flex flex-row items-center space-x-2 z-10"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleIgnoreSafeArea}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={ignoreSafeArea ? "contract-outline" : "expand"}
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
stopPlayback();
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 8,
|
||||||
|
left: isLandscape ? insets.left + 32 : insets.left + 16,
|
||||||
|
width: isLandscape
|
||||||
|
? dimensions.window.width - insets.left - insets.right - 64
|
||||||
|
: dimensions.window.width - insets.left - insets.right - 32,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||||
|
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
|
||||||
|
{currentlyPlaying.item?.Type === "Episode" && (
|
||||||
|
<Text className="opacity-50">
|
||||||
|
{currentlyPlaying.item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Movie" && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Audio" && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.Album}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`flex ${
|
||||||
|
isLandscape
|
||||||
|
? "flex-row space-x-6 py-2 px-4 rounded-full"
|
||||||
|
: "flex-col-reverse py-4 px-4 rounded-2xl"
|
||||||
|
}
|
||||||
|
items-center bg-neutral-800`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !previousItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleGoToPreviousItem}
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
|
<Ionicons
|
||||||
|
name="refresh-outline"
|
||||||
|
size={26}
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handlePlayPause}>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={30}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<Ionicons name="refresh-outline" size={26} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !nextItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleGoToNextItem}
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`flex flex-col w-full shrink
|
||||||
|
${""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
bubbleBackgroundColor: "#fff",
|
||||||
|
bubbleTextColor: "#000",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
cache={cacheProgress}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
renderBubble={() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
marginLeft: -tileWidth / 4,
|
||||||
|
marginTop: -tileHeight / 4 - 60,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
className=" bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sliderHeight={10}
|
||||||
|
thumbWidth={0}
|
||||||
|
progress={progress}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
/>
|
||||||
|
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{formatTimeString(currentTime)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{formatTimeString(remainingTime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
components/GenreTags.tsx
Normal file
25
components/GenreTags.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// GenreTags.tsx
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface GenreTagsProps {
|
||||||
|
genres?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
||||||
|
if (!genres || genres.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row flex-wrap mt-2">
|
||||||
|
{genres.map((genre) => (
|
||||||
|
<View
|
||||||
|
key={genre}
|
||||||
|
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
|
||||||
|
>
|
||||||
|
<Text className="text-xs">{genre}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ type ItemCardProps = {
|
|||||||
|
|
||||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||||
return (
|
return (
|
||||||
<View className="mt-2 flex flex-col h-12">
|
<View className="mt-2 flex flex-col">
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={2} className="">
|
<Text numberOfLines={2} className="">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { Stack, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -43,6 +43,7 @@ import { Chromecast } from "./Chromecast";
|
|||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -56,7 +57,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
useState<MediaSourceInfo | null>(null);
|
useState<MediaSourceInfo | null>(null);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(-1);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -124,6 +125,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [localItem, setLocalItem] = useState(item);
|
const [localItem, setLocalItem] = useState(item);
|
||||||
|
useImageColors(item);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -167,7 +169,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;
|
else headerHeightRef.current = 400;
|
||||||
}, [item]);
|
}, [item, orientation]);
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
const { data: sessionData } = useQuery({
|
||||||
queryKey: ["sessionData", item?.Id],
|
queryKey: ["sessionData", item?.Id],
|
||||||
@@ -233,18 +235,6 @@ 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(isLoading || isFetching || (logoUrl && loadingLogo));
|
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
|
||||||
@@ -273,7 +263,6 @@ 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"
|
||||||
@@ -356,10 +345,23 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
|||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OverviewText text={item?.Overview} className="px-4 mb-4" />
|
<OverviewText text={item?.Overview} className="px-4 my-4" />
|
||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
|
{item?.People && item.People.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
{item.People.slice(0, 3).map((person) => (
|
||||||
|
<MoreMoviesWithActor
|
||||||
|
currentItem={item}
|
||||||
|
key={person.Id}
|
||||||
|
actorId={person.Id!}
|
||||||
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
|
import { GenreTags } from "./GenreTags";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -12,7 +14,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
if (!item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex flex-col space-y-1.5 w-full items-start h-24"
|
className="flex flex-col space-y-1.5 w-full items-start h-32"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||||
@@ -23,16 +25,22 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View className="flex flex-col" {...props}>
|
||||||
style={{
|
<View className="flex flex-col" {...props}>
|
||||||
minHeight: 96,
|
<Ratings item={item} className="mb-2" />
|
||||||
}}
|
{item.Type === "Episode" && (
|
||||||
className="flex flex-col"
|
<>
|
||||||
{...props}
|
<EpisodeTitleHeader item={item} />
|
||||||
>
|
<GenreTags genres={item.Genres!} />
|
||||||
<Ratings item={item} className="mb-2" />
|
</>
|
||||||
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
|
)}
|
||||||
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
|
{item.Type === "Movie" && (
|
||||||
|
<>
|
||||||
|
<MoviesTitleHeader item={item} />
|
||||||
|
<GenreTags genres={item.Genres!} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<Text className="font-bold ">{title}</Text>
|
<Text className="font-bold ">{title}</Text>
|
||||||
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
{subTitle && (
|
||||||
|
<Text className="text-xs" selectable>
|
||||||
|
{subTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{iconAfter}
|
{iconAfter}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -78,7 +79,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
{name(source.Name)}
|
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||||
|
source.Size
|
||||||
|
)}`}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
100
components/MoreMoviesWithActor.tsx
Normal file
100
components/MoreMoviesWithActor.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
actorId: string;
|
||||||
|
currentItem: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||||
|
actorId,
|
||||||
|
currentItem,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { data: actor } = useQuery({
|
||||||
|
queryKey: ["actor", actorId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return null;
|
||||||
|
return await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: actorId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!actorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: items, isLoading } = useQuery({
|
||||||
|
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
personIds: [actorId],
|
||||||
|
limit: 20,
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
includeItemTypes: ["Movie", "Series"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
||||||
|
sortBy: ["PremiereDate"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove duplicates based on item ID
|
||||||
|
const uniqueItems =
|
||||||
|
response.data.Items?.reduce((acc, current) => {
|
||||||
|
const x = acc.find((item) => item.Id === current.Id);
|
||||||
|
if (!x) {
|
||||||
|
return acc.concat([current]);
|
||||||
|
} else {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
}, [] as BaseItemDto[]) || [];
|
||||||
|
|
||||||
|
return uniqueItems;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!actorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items?.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="text-lg font-bold mb-2 px-4">
|
||||||
|
More with {actor?.Name}
|
||||||
|
</Text>
|
||||||
|
<HorizontalScroll
|
||||||
|
data={items}
|
||||||
|
loading={isLoading}
|
||||||
|
height={247}
|
||||||
|
renderItem={(item: BaseItemDto, idx: number) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={idx}
|
||||||
|
item={item}
|
||||||
|
className="flex flex-col w-28"
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ export const OverviewText: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="text-xl font-bold mb-2">Overview</Text>
|
<Text className="text-lg font-bold mb-2">Overview</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) =>
|
setLimit((prev) =>
|
||||||
|
|||||||
35
components/PlatformBlurView.tsx
Normal file
35
components/PlatformBlurView.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React from "react";
|
||||||
|
import { Platform, View, ViewProps } from "react-native";
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
blurAmount?: number;
|
||||||
|
blurType?: "light" | "dark" | "xlight";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlurView for iOS and simple View for Android
|
||||||
|
*/
|
||||||
|
export const PlatformBlurView: React.FC<Props> = ({
|
||||||
|
blurAmount = 100,
|
||||||
|
blurType = "light",
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<BlurView style={style} intensity={blurAmount} {...props}>
|
||||||
|
{children}
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[{ backgroundColor: "rgba(50, 50, 50, 0.9)" }, style]}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -45,6 +46,8 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||||
|
|
||||||
@@ -57,12 +60,13 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
|
|
||||||
const directStream = useMemo(() => {
|
const directStream = useMemo(() => {
|
||||||
return !url?.includes("m3u8");
|
return !url?.includes("m3u8");
|
||||||
}, []);
|
}, [url]);
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!url || !item) return;
|
if (!url || !item) return;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
setCurrentlyPlayingState({ item, url });
|
setCurrentlyPlayingState({ item, url });
|
||||||
|
router.push("/play");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
const options = ["Chromecast", "Device", "Cancel"];
|
||||||
@@ -160,6 +164,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
setCurrentlyPlayingState({ item, url });
|
setCurrentlyPlayingState({ item, url });
|
||||||
|
router.push("/play");
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
@@ -226,7 +231,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.average, endColor.value.average]
|
[startColor.value.primary, endColor.value.primary]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -279,7 +284,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle]}
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["item"],
|
queryKey: ["item", item.Id],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems"],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["continueWatching"],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["nextUp-all"],
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["nextUp"],
|
queryKey: ["nextUp"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { ItemCardText } from "./ItemCardText";
|
import { ItemCardText } from "./ItemCardText";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
||||||
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
|
|
||||||
interface SimilarItemsProps extends ViewProps {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
@@ -46,29 +48,24 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
||||||
{isLoading ? (
|
<HorizontalScroll
|
||||||
<View className="my-12">
|
data={movies}
|
||||||
<Loader />
|
loading={isLoading}
|
||||||
</View>
|
height={247}
|
||||||
) : (
|
noItemsText="No similar items found"
|
||||||
<ScrollView horizontal>
|
renderItem={(item: BaseItemDto, idx: number) => (
|
||||||
<View className="px-4 flex flex-row gap-x-2">
|
<TouchableItemRouter
|
||||||
{movies.map((item) => (
|
key={idx}
|
||||||
<TouchableOpacity
|
item={item}
|
||||||
key={item.Id}
|
className="flex flex-col w-28"
|
||||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
>
|
||||||
className="flex flex-col w-32"
|
<View>
|
||||||
>
|
<MoviePoster item={item} />
|
||||||
<MoviePoster item={item} />
|
<ItemCardText item={item} />
|
||||||
<ItemCardText item={item} />
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
))}
|
)}
|
||||||
</View>
|
/>
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
{movies.length === 0 && (
|
|
||||||
<Text className="px-4 text-neutral-500">No similar items</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface HorizontalScrollProps<T>
|
|||||||
height?: number;
|
height?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
extraData?: any;
|
extraData?: any;
|
||||||
|
noItemsText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HorizontalScroll = forwardRef<
|
export const HorizontalScroll = forwardRef<
|
||||||
@@ -38,6 +39,7 @@ export const HorizontalScroll = forwardRef<
|
|||||||
loading = false,
|
loading = false,
|
||||||
height = 164,
|
height = 164,
|
||||||
extraData,
|
extraData,
|
||||||
|
noItemsText,
|
||||||
...props
|
...props
|
||||||
}: HorizontalScrollProps<T>,
|
}: HorizontalScrollProps<T>,
|
||||||
ref: React.ForwardedRef<HorizontalScrollRef>
|
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||||
@@ -91,10 +93,11 @@ export const HorizontalScroll = forwardRef<
|
|||||||
}}
|
}}
|
||||||
ListEmptyComponent={() => (
|
ListEmptyComponent={() => (
|
||||||
<View className="flex-1 justify-center items-center">
|
<View className="flex-1 justify-center items-center">
|
||||||
<Text className="text-center text-gray-500">No data available</Text>
|
<Text className="text-center text-gray-500">
|
||||||
|
{noItemsText || "No data available"}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface Props extends ImageProps {
|
|||||||
| "Thumb";
|
| "Thumb";
|
||||||
quality?: number;
|
quality?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
useThemeColor?: boolean;
|
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ export const ItemImage: React.FC<Props> = ({
|
|||||||
variant = "Primary",
|
variant = "Primary",
|
||||||
quality = 90,
|
quality = 90,
|
||||||
width = 1000,
|
width = 1000,
|
||||||
useThemeColor = false,
|
|
||||||
onError,
|
onError,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -8,6 +8,42 @@ 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") {
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "CollectionFolder") {
|
|
||||||
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same as default
|
|
||||||
// if (item.Type === "Episode") {
|
|
||||||
// router.push(`/items/${item.Id}`);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
191
components/downloads/ActiveDownloads.tsx
Normal file
191
components/downloads/ActiveDownloads.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const { processes, startDownload } = useDownload();
|
||||||
|
if (processes?.length === 0)
|
||||||
|
return (
|
||||||
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
|
<Text className="text-lg font-bold">Active download</Text>
|
||||||
|
<Text className="opacity-50">No active downloads</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
|
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
||||||
|
<View className="space-y-2">
|
||||||
|
{processes?.map((p) => (
|
||||||
|
<DownloadCard key={p.id} process={p} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DownloadCardProps extends TouchableOpacityProps {
|
||||||
|
process: JobStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
|
const { processes, startDownload } = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
const { removeProcess, setProcesses } = useDownload();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const cancelJobMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
if (!process) throw new Error("No active download");
|
||||||
|
|
||||||
|
if (settings?.downloadMethod === "optimized") {
|
||||||
|
try {
|
||||||
|
const tasks = await checkForExistingDownloads();
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.id === id) {
|
||||||
|
task.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await removeProcess(id);
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FFmpegKit.cancel();
|
||||||
|
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Download canceled");
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
console.log(e);
|
||||||
|
toast.error("Could not cancel download");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eta = (p: JobStatus) => {
|
||||||
|
if (!p.speed || !p.progress) return null;
|
||||||
|
|
||||||
|
const length = p?.item?.RunTimeTicks || 0;
|
||||||
|
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||||
|
return formatTimeString(timeLeft, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64Image = useMemo(() => {
|
||||||
|
return storage.getString(process.item.Id!);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
||||||
|
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{(process.status === "optimizing" ||
|
||||||
|
process.status === "downloading") && (
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
width: process.progress
|
||||||
|
? `${Math.max(5, process.progress)}%`
|
||||||
|
: "5%",
|
||||||
|
}}
|
||||||
|
></View>
|
||||||
|
)}
|
||||||
|
<View className="px-3 py-1.5 flex flex-col w-full">
|
||||||
|
<View className="flex flex-row items-center w-full">
|
||||||
|
{base64Image && (
|
||||||
|
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="shrink mb-1">
|
||||||
|
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
||||||
|
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{process.item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||||
|
{process.progress === 0 ? (
|
||||||
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
) : (
|
||||||
|
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
|
||||||
|
)}
|
||||||
|
{process.speed && (
|
||||||
|
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
||||||
|
)}
|
||||||
|
{eta(process) && (
|
||||||
|
<Text className="text-xs">ETA {eta(process)}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||||
|
<Text className="text-xs capitalize">{process.status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={cancelJobMutation.isPending}
|
||||||
|
onPress={() => cancelJobMutation.mutate(process.id)}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
{cancelJobMutation.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="close" size={24} color="red" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{process.status === "completed" && (
|
||||||
|
<View className="flex flex-row mt-4 space-x-4">
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
startDownload(process);
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Download now
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,36 +1,41 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import * as FileSystem from "expo-file-system";
|
import React, { useCallback, useMemo, useRef } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import {
|
||||||
|
ActionSheetProvider,
|
||||||
|
useActionSheet,
|
||||||
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
|
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
import { Image } from "expo-image";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EpisodeCard component displays an episode with context menu options.
|
* EpisodeCard component displays an episode with action sheet options.
|
||||||
* @param {EpisodeCardProps} props - The component props.
|
* @param {EpisodeCardProps} props - The component props.
|
||||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
||||||
*/
|
*/
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useFiles();
|
const { deleteFile } = useDownload();
|
||||||
|
const { openFile } = useFileOpener();
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const { startDownloadedFilePlayback } = usePlayback();
|
const base64Image = useMemo(() => {
|
||||||
|
return storage.getString(item.Id!);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(async () => {
|
const handleOpenFile = useCallback(() => {
|
||||||
startDownloadedFilePlayback({
|
openFile(item);
|
||||||
item,
|
}, [item, openFile]);
|
||||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
}, [item, startDownloadedFilePlayback]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
@@ -42,43 +47,70 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
const contextMenuOptions = [
|
const showActionSheet = useCallback(() => {
|
||||||
{
|
const options = ["Delete", "Cancel"];
|
||||||
label: "Delete",
|
const destructiveButtonIndex = 0;
|
||||||
onSelect: handleDeleteFile,
|
const cancelButtonIndex = 1;
|
||||||
destructive: true,
|
|
||||||
},
|
showActionSheetWithOptions(
|
||||||
];
|
{
|
||||||
|
options,
|
||||||
|
cancelButtonIndex,
|
||||||
|
destructiveButtonIndex,
|
||||||
|
},
|
||||||
|
(selectedIndex) => {
|
||||||
|
switch (selectedIndex) {
|
||||||
|
case destructiveButtonIndex:
|
||||||
|
// Delete
|
||||||
|
handleDeleteFile();
|
||||||
|
break;
|
||||||
|
case cancelButtonIndex:
|
||||||
|
// Cancelled
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={handleOpenFile}
|
||||||
<TouchableOpacity
|
onLongPress={showActionSheet}
|
||||||
onPress={handleOpenFile}
|
className="flex flex-col"
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
>
|
||||||
>
|
{base64Image ? (
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
<View className="w-44 aspect-video rounded-lg overflow-hidden mr-2">
|
||||||
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
<Image
|
||||||
</TouchableOpacity>
|
source={{
|
||||||
</ContextMenu.Trigger>
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
<ContextMenu.Content
|
}}
|
||||||
alignOffset={0}
|
style={{
|
||||||
avoidCollisions
|
width: "100%",
|
||||||
collisionPadding={10}
|
height: "100%",
|
||||||
loop={false}
|
resizeMode: "cover",
|
||||||
>
|
}}
|
||||||
{contextMenuOptions.map((option) => (
|
/>
|
||||||
<ContextMenu.Item
|
</View>
|
||||||
key={option.label}
|
) : (
|
||||||
onSelect={option.onSelect}
|
<View className="w-44 aspect-video rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||||
destructive={option.destructive}
|
<Ionicons
|
||||||
>
|
name="image-outline"
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
size={24}
|
||||||
{option.label}
|
color="gray"
|
||||||
</ContextMenu.ItemTitle>
|
className="self-center mt-16"
|
||||||
</ContextMenu.Item>
|
/>
|
||||||
))}
|
</View>
|
||||||
</ContextMenu.Content>
|
)}
|
||||||
</ContextMenu.Root>
|
<ItemCardText item={item} />
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap the parent component with ActionSheetProvider
|
||||||
|
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
|
||||||
|
props
|
||||||
|
) => (
|
||||||
|
<ActionSheetProvider>
|
||||||
|
<EpisodeCard {...props} />
|
||||||
|
</ActionSheetProvider>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,39 +1,43 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import {
|
||||||
|
ActionSheetProvider,
|
||||||
|
useActionSheet,
|
||||||
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MovieCard component displays a movie with context menu options.
|
* MovieCard component displays a movie with action sheet options.
|
||||||
* @param {MovieCardProps} props - The component props.
|
* @param {MovieCardProps} props - The component props.
|
||||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
* @returns {React.ReactElement} The rendered MovieCard component.
|
||||||
*/
|
*/
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useFiles();
|
const { deleteFile } = useDownload();
|
||||||
const [settings] = useSettings();
|
const { openFile } = useFileOpener();
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const { startDownloadedFilePlayback } = usePlayback();
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
startDownloadedFilePlayback({
|
openFile(item);
|
||||||
item,
|
}, [item, openFile]);
|
||||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
const base64Image = useMemo(() => {
|
||||||
}, [item, startDownloadedFilePlayback]);
|
return storage.getString(item.Id!);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
@@ -45,48 +49,64 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
const contextMenuOptions = [
|
const showActionSheet = useCallback(() => {
|
||||||
{
|
const options = ["Delete", "Cancel"];
|
||||||
label: "Delete",
|
const destructiveButtonIndex = 0;
|
||||||
onSelect: handleDeleteFile,
|
const cancelButtonIndex = 1;
|
||||||
destructive: true,
|
|
||||||
},
|
showActionSheetWithOptions(
|
||||||
];
|
{
|
||||||
|
options,
|
||||||
|
cancelButtonIndex,
|
||||||
|
destructiveButtonIndex,
|
||||||
|
},
|
||||||
|
(selectedIndex) => {
|
||||||
|
switch (selectedIndex) {
|
||||||
|
case destructiveButtonIndex:
|
||||||
|
// Delete
|
||||||
|
handleDeleteFile();
|
||||||
|
break;
|
||||||
|
case cancelButtonIndex:
|
||||||
|
// Cancelled
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
||||||
<ContextMenu.Trigger>
|
{base64Image ? (
|
||||||
<TouchableOpacity
|
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
||||||
onPress={handleOpenFile}
|
<Image
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
source={{
|
||||||
>
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
}}
|
||||||
<View className="flex flex-col">
|
style={{
|
||||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
width: "100%",
|
||||||
<Text className="text-xs opacity-50">
|
height: "100%",
|
||||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
resizeMode: "cover",
|
||||||
</Text>
|
}}
|
||||||
</View>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</ContextMenu.Trigger>
|
) : (
|
||||||
<ContextMenu.Content
|
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||||
loop={false}
|
<Ionicons
|
||||||
alignOffset={0}
|
name="image-outline"
|
||||||
avoidCollisions={false}
|
size={24}
|
||||||
collisionPadding={0}
|
color="gray"
|
||||||
>
|
className="self-center mt-16"
|
||||||
{contextMenuOptions.map((option) => (
|
/>
|
||||||
<ContextMenu.Item
|
</View>
|
||||||
key={option.label}
|
)}
|
||||||
onSelect={option.onSelect}
|
<ItemCardText item={item} />
|
||||||
destructive={option.destructive}
|
</TouchableOpacity>
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
|
||||||
{option.label}
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
))}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap the parent component with ActionSheetProvider
|
||||||
|
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
|
||||||
|
<ActionSheetProvider>
|
||||||
|
<MovieCard {...props} />
|
||||||
|
</ActionSheetProvider>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
import { EpisodeCard } from "./EpisodeCard";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -22,26 +22,32 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
);
|
);
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
||||||
|
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className="flex flex-row items-center justify-between px-4">
|
||||||
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
|
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
<Text className="text-xs font-bold">{items.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2">TV-Series</Text>
|
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
{groupBySeason.map((seasonItems, seasonIndex) => (
|
||||||
<View key={seasonIndex}>
|
<View key={seasonIndex}>
|
||||||
<Text className="mb-2 font-semibold">
|
<Text className="mb-2 font-semibold px-4">
|
||||||
{seasonItems[0].SeasonName}
|
{seasonItems[0].SeasonName}
|
||||||
</Text>
|
</Text>
|
||||||
{seasonItems.map((item, index) => (
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="mb-2" key={index}>
|
<View className="px-4 flex flex-row">
|
||||||
<EpisodeCard item={item} />
|
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
||||||
|
<EpisodeCard item={item} />
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
))}
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { Dimensions, View, ViewProps } from "react-native";
|
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import Carousel, {
|
import Carousel, {
|
||||||
ICarouselInstance,
|
ICarouselInstance,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from "react-native-reanimated-carousel";
|
} from "react-native-reanimated-carousel";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const ref = React.useRef<ICarouselInstance>(null);
|
const ref = React.useRef<ICarouselInstance>(null);
|
||||||
@@ -80,13 +84,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const width = Dimensions.get("screen").width;
|
const width = Dimensions.get("screen").width;
|
||||||
|
|
||||||
if (l1 || l2)
|
if (l1 || l2) return null;
|
||||||
return (
|
|
||||||
<View className="h-[242px] flex items-center justify-center">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +120,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
@@ -141,11 +139,41 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
return getLogoImageUrlById({ api, item, height: 100 });
|
return getLogoImageUrlById({ api, item, height: 100 });
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = segments[2];
|
||||||
|
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const handleRoute = useCallback(() => {
|
||||||
|
if (!from) return;
|
||||||
|
const url = itemRouter(item, from);
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
// @ts-ignore
|
||||||
|
if (url) router.push(url);
|
||||||
|
}, [item, from]);
|
||||||
|
|
||||||
|
const tap = Gesture.Tap()
|
||||||
|
.maxDuration(2000)
|
||||||
|
.onBegin(() => {
|
||||||
|
opacity.value = withTiming(0.5, { duration: 100 });
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
runOnJS(handleRoute)();
|
||||||
|
})
|
||||||
|
.onFinalize(() => {
|
||||||
|
opacity.value = withTiming(1, { duration: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
if (!uri || !logoUri) return null;
|
if (!uri || !logoUri) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableItemRouter item={item}>
|
<GestureDetector gesture={tap}>
|
||||||
<View className="px-4">
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
opacity: opacity,
|
||||||
|
}}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
@@ -171,7 +199,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</TouchableItemRouter>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
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 {
|
import {
|
||||||
useQuery,
|
useQuery,
|
||||||
type QueryFunction,
|
type QueryFunction,
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { View, ViewProps } from "react-native";
|
import { ScrollView, 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 SeriesPoster from "../posters/SeriesPoster";
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
height?: "small" | "large";
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
@@ -26,13 +25,11 @@ interface Props extends ViewProps {
|
|||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
title,
|
title,
|
||||||
orientation = "vertical",
|
orientation = "vertical",
|
||||||
height = "small",
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [settings] = useSettings();
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
@@ -43,41 +40,70 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props} className="">
|
||||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
{isLoading ? (
|
||||||
data={data}
|
<View
|
||||||
height={orientation === "vertical" ? 247 : 164}
|
className={`
|
||||||
loading={isLoading}
|
flex flex-row gap-2 px-4
|
||||||
renderItem={(item, index) => (
|
`}
|
||||||
<TouchableItemRouter
|
>
|
||||||
key={index}
|
{[1, 2, 3].map((i) => (
|
||||||
item={item}
|
<View className="w-44" key={i}>
|
||||||
className={`flex flex-col
|
<View className="bg-neutral-900 h-24 w-full rounded-md mb-1"></View>
|
||||||
|
<View className="rounded-md overflow-hidden mb-1 self-start">
|
||||||
|
<Text
|
||||||
|
className="text-neutral-900 bg-neutral-900 rounded-md"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Nisi mollit voluptate amet.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="rounded-md overflow-hidden self-start mb-1">
|
||||||
|
<Text
|
||||||
|
className="text-neutral-900 bg-neutral-900 text-xs rounded-md "
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Lorem ipsum
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="px-4 flex flex-row">
|
||||||
|
{data?.map((item, index) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={index}
|
||||||
|
className={`
|
||||||
|
mr-2
|
||||||
|
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View>
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
<ContinueWatchingPoster item={item} />
|
||||||
<ContinueWatchingPoster item={item} />
|
)}
|
||||||
)}
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
<SeriesPoster item={item} />
|
||||||
<SeriesPoster item={item} />
|
)}
|
||||||
)}
|
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
<ContinueWatchingPoster item={item} />
|
||||||
<ContinueWatchingPoster item={item} />
|
)}
|
||||||
)}
|
{item.Type === "Movie" && orientation === "vertical" && (
|
||||||
{item.Type === "Movie" && orientation === "vertical" && (
|
<MoviePoster item={item} />
|
||||||
<MoviePoster item={item} />
|
)}
|
||||||
)}
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
<ItemCardText item={item} />
|
||||||
<ItemCardText item={item} />
|
</TouchableItemRouter>
|
||||||
</View>
|
))}
|
||||||
</TouchableItemRouter>
|
</View>
|
||||||
)}
|
</ScrollView>
|
||||||
/>
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
{library.Name}
|
{library.Name}
|
||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
|
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
||||||
{itemsCount} items
|
{itemsCount} items
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ interface Props extends ViewProps {
|
|||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
|
<Text className=" font-bold text-2xl mb-1" selectable>
|
||||||
|
{item?.Name}
|
||||||
|
</Text>
|
||||||
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { runtimeTicksToSeconds } from "@/utils/time";
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
@@ -35,7 +36,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
|
const router = useRouter();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
item,
|
item,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
router.push("/play-music");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||||
<Image
|
<Image
|
||||||
placeholder={{
|
placeholder={{
|
||||||
blurhash,
|
blurhash,
|
||||||
@@ -57,7 +57,6 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WatchedIndicator item={item} />
|
<WatchedIndicator item={item} />
|
||||||
{showProgress && progress > 0 && (
|
{showProgress && progress > 0 && (
|
||||||
<View className="h-1 bg-red-600 w-full"></View>
|
<View className="h-1 bg-red-600 w-full"></View>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
<View className="w-28 aspect-[10/15] relative rounded-lg overflow-hidden border border-neutral-900 ">
|
||||||
<Image
|
<Image
|
||||||
placeholder={{
|
placeholder={{
|
||||||
blurhash,
|
blurhash,
|
||||||
@@ -49,7 +49,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
|||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "10/15",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
height={247}
|
||||||
data={item?.People || []}
|
data={item?.People || []}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -32,7 +33,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
router.push(`/actors/${item.Id}`);
|
router.push(`/actors/${item.Id}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
||||||
<Text className="mt-2">{item.Name}</Text>
|
<Text className="mt-2">{item.Name}</Text>
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={[item]}
|
data={[item]}
|
||||||
|
height={247}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/series/${item.SeriesId}`)}
|
onPress={() => router.push(`/series/${item.SeriesId}`)}
|
||||||
className="flex flex-col space-y-2 w-32"
|
className="flex flex-col space-y-2 w-28"
|
||||||
>
|
>
|
||||||
<Poster
|
<Poster
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="font-bold text-2xl">{item?.Name}</Text>
|
<Text className="font-bold text-2xl" selectable>
|
||||||
|
{item?.Name}
|
||||||
|
</Text>
|
||||||
<View className="flex flex-row items-center mb-1">
|
<View className="flex flex-row items-center mb-1">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -46,16 +47,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
onPress={() => {
|
item={item}
|
||||||
router.push(`/(auth)/items/page?id=${item.Id}`);
|
key={index}
|
||||||
}}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-44"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} useEpisodePoster />
|
<ContinueWatchingPoster item={item} useEpisodePoster />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -192,18 +193,16 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
episodes?.map((e: BaseItemDto) => (
|
episodes?.map((e: BaseItemDto) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
|
item={e}
|
||||||
key={e.Id}
|
key={e.Id}
|
||||||
onPress={() => {
|
|
||||||
router.push(`/(auth)/items/page?id=${e.Id}`);
|
|
||||||
}}
|
|
||||||
className="flex flex-col mb-4"
|
className="flex flex-col mb-4"
|
||||||
>
|
>
|
||||||
<View className="flex flex-row items-center mb-2">
|
<View className="flex flex-row items-start mb-2">
|
||||||
<View className="w-32 aspect-video overflow-hidden mr-2">
|
<View className="mr-2">
|
||||||
<ContinueWatchingPoster
|
<ContinueWatchingPoster
|
||||||
|
size="small"
|
||||||
item={e}
|
item={e}
|
||||||
width={128}
|
|
||||||
useEpisodePoster
|
useEpisodePoster
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -218,7 +217,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start ml-auto">
|
<View className="self-start ml-auto -mt-0.5">
|
||||||
<DownloadItem item={e} />
|
<DownloadItem item={e} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -229,7 +228,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
>
|
>
|
||||||
{e.Overview}
|
{e.Overview}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,39 +1,16 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
DefaultLanguageOption,
|
|
||||||
DownloadOptions,
|
|
||||||
useSettings,
|
|
||||||
} from "@/utils/atoms/settings";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
Switch,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
import { Input } from "../common/Input";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
|
|
||||||
const LANGUAGES: DefaultLanguageOption[] = [
|
|
||||||
{ label: "eng", value: "eng" },
|
|
||||||
{
|
|
||||||
label: "sv",
|
|
||||||
value: "sv",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||||
@@ -144,6 +121,82 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Forward skip length</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose length in seconds when skipping in video playback.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
|
{settings.forwardSkipTime}s
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Rewind length</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose length in seconds when skipping in video playback.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
|
{settings.rewindSkipTime}s
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +1,85 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
DefaultLanguageOption,
|
apiAtom,
|
||||||
DownloadOptions,
|
getOrSetDeviceId,
|
||||||
useSettings,
|
userAtom,
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
BACKGROUND_FETCH_TASK,
|
||||||
|
registerBackgroundFetchAsync,
|
||||||
|
unregisterBackgroundFetchAsync,
|
||||||
|
} from "@/utils/background-tasks";
|
||||||
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 * as BackgroundFetch from "expo-background-fetch";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Input } from "../common/Input";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
|
import axios from "axios";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
|
|
||||||
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 { setProcesses } = useDownload();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||||
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
|
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/********************
|
||||||
|
* Background task
|
||||||
|
*******************/
|
||||||
|
const checkStatusAsync = async () => {
|
||||||
|
await BackgroundFetch.getStatusAsync();
|
||||||
|
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const registered = await checkStatusAsync();
|
||||||
|
|
||||||
|
if (settings?.autoDownload === true && !registered) {
|
||||||
|
registerBackgroundFetchAsync();
|
||||||
|
toast.success("Background downlodas enabled");
|
||||||
|
} else if (settings?.autoDownload === false && registered) {
|
||||||
|
unregisterBackgroundFetchAsync();
|
||||||
|
toast.info("Background downloads disabled");
|
||||||
|
} else if (settings?.autoDownload === true && registered) {
|
||||||
|
// Don't to anything
|
||||||
|
} else if (settings?.autoDownload === false && !registered) {
|
||||||
|
// Don't to anything
|
||||||
|
} else {
|
||||||
|
updateSettings({ autoDownload: false });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [settings?.autoDownload]);
|
||||||
|
/**********************
|
||||||
|
*********************/
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: mediaListCollections,
|
data: mediaListCollections,
|
||||||
isLoading: isLoadingMediaListCollections,
|
isLoading: isLoadingMediaListCollections,
|
||||||
@@ -48,8 +102,10 @@ export const SettingToggles: React.FC = () => {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
{/* <View>
|
{/* <View>
|
||||||
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
||||||
@@ -70,7 +126,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="shrink">
|
<View className="shrink">
|
||||||
<Text className="font-semibold">Auto rotate</Text>
|
<Text className="font-semibold">Auto rotate</Text>
|
||||||
@@ -80,25 +136,116 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings?.autoRotate}
|
value={settings.autoRotate}
|
||||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View
|
||||||
<View className="shrink">
|
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
||||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
className={`
|
||||||
|
${
|
||||||
|
settings.autoRotate
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: "opacity-100"
|
||||||
|
}
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Video orientation</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Clicking a video will start it in fullscreen mode, instead of
|
Set the full screen video player orientation.
|
||||||
inline.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<DropdownMenu.Root>
|
||||||
value={settings?.openFullScreenVideoPlayerByDefault}
|
<DropdownMenu.Trigger>
|
||||||
onValueChange={(value) =>
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
updateSettings({ openFullScreenVideoPlayerByDefault: value })
|
<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>
|
||||||
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
@@ -110,7 +257,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings?.openInVLC}
|
value={settings.openInVLC}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||||
}}
|
}}
|
||||||
@@ -133,13 +280,13 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings?.usePopularPlugin}
|
value={settings.usePopularPlugin}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ usePopularPlugin: value })
|
updateSettings({ usePopularPlugin: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{settings?.usePopularPlugin && (
|
{settings.usePopularPlugin && (
|
||||||
<View className="flex flex-col py-2 bg-neutral-900">
|
<View className="flex flex-col py-2 bg-neutral-900">
|
||||||
{mediaListCollections?.map((mlc) => (
|
{mediaListCollections?.map((mlc) => (
|
||||||
<View
|
<View
|
||||||
@@ -150,9 +297,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
<Text className="font-semibold">{mlc.Name}</Text>
|
<Text className="font-semibold">{mlc.Name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings?.mediaListCollectionIds?.includes(
|
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||||
mlc.Id!
|
|
||||||
)}
|
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (!settings.mediaListCollectionIds) {
|
if (!settings.mediaListCollectionIds) {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -163,11 +308,11 @@ export const SettingToggles: React.FC = () => {
|
|||||||
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
mediaListCollectionIds:
|
mediaListCollectionIds:
|
||||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
settings.mediaListCollectionIds.includes(mlc.Id!)
|
||||||
? settings?.mediaListCollectionIds.filter(
|
? settings.mediaListCollectionIds.filter(
|
||||||
(id) => id !== mlc.Id
|
(id) => id !== mlc.Id
|
||||||
)
|
)
|
||||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
: [...settings.mediaListCollectionIds, mlc.Id!],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -198,7 +343,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings?.forceDirectPlay}
|
value={settings.forceDirectPlay}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ forceDirectPlay: value })
|
updateSettings({ forceDirectPlay: value })
|
||||||
}
|
}
|
||||||
@@ -207,9 +352,9 @@ export const SettingToggles: React.FC = () => {
|
|||||||
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Device profile</Text>
|
<Text className="font-semibold">Device profile</Text>
|
||||||
@@ -221,7 +366,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text>{settings?.deviceProfile}</Text>
|
<Text>{settings.deviceProfile}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -261,11 +406,12 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Search engine</Text>
|
<Text className="font-semibold">Search engine</Text>
|
||||||
@@ -276,7 +422,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text>{settings?.searchEngine}</Text>
|
<Text>{settings.searchEngine}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -310,42 +456,179 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
{settings?.searchEngine === "Marlin" && (
|
{settings.searchEngine === "Marlin" && (
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||||
<>
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="grow">
|
||||||
<View className="grow">
|
<Input
|
||||||
<Input
|
placeholder="Marlin Server URL..."
|
||||||
placeholder="Marlin Server URL..."
|
defaultValue={settings.marlinServerUrl}
|
||||||
defaultValue={settings.marlinServerUrl}
|
value={marlinUrl}
|
||||||
value={marlinUrl}
|
keyboardType="url"
|
||||||
keyboardType="url"
|
returnKeyType="done"
|
||||||
returnKeyType="done"
|
autoCapitalize="none"
|
||||||
autoCapitalize="none"
|
textContentType="URL"
|
||||||
textContentType="URL"
|
onChangeText={(text) => setMarlinUrl(text)}
|
||||||
onChangeText={(text) => setMarlinUrl(text)}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
className="shrink w-16 h-12"
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ marlinServerUrl: marlinUrl });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
className="shrink w-16 h-12"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: marlinUrl.endsWith("/")
|
||||||
|
? marlinUrl
|
||||||
|
: marlinUrl + "/",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings.marlinServerUrl && (
|
||||||
<Text className="text-neutral-500 mt-2">
|
<Text className="text-neutral-500 mt-2">
|
||||||
{settings?.marlinServerUrl}
|
Current: {settings.marlinServerUrl}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Downloads</Text>
|
||||||
|
<View className="flex flex-col rounded-xl 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">Download method</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose the download method to use. Optimized requires the
|
||||||
|
optimized server.
|
||||||
|
</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.downloadMethod === "remux"
|
||||||
|
? "Default"
|
||||||
|
: "Optimized"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ downloadMethod: "remux" });
|
||||||
|
setProcesses([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ downloadMethod: "optimized" });
|
||||||
|
setProcesses([]);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</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">Auto download</Text>
|
||||||
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
This will automatically download the media file when it's
|
||||||
|
finished optimizing on the server.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.autoDownload}
|
||||||
|
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
pointerEvents={
|
||||||
|
settings.downloadMethod === "optimized" ? "auto" : "none"
|
||||||
|
}
|
||||||
|
className={`
|
||||||
|
${
|
||||||
|
settings.downloadMethod === "optimized"
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col bg-neutral-900 px-4 py-4">
|
||||||
|
<View className="flex flex-col shrink mb-2">
|
||||||
|
<View className="flex flex-row justify-between items-center">
|
||||||
|
<Text className="font-semibold">
|
||||||
|
Optimized versions server
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Set the URL for the optimized versions server for downloads.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View></View>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Input
|
||||||
|
placeholder="Optimized versions server URL..."
|
||||||
|
value={optimizedVersionsServerUrl}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
className="h-12 mt-2"
|
||||||
|
onPress={async () => {
|
||||||
|
updateSettings({
|
||||||
|
optimizedVersionsServerUrl:
|
||||||
|
optimizedVersionsServerUrl.length === 0
|
||||||
|
? null
|
||||||
|
: optimizedVersionsServerUrl.endsWith("/")
|
||||||
|
? optimizedVersionsServerUrl
|
||||||
|
: optimizedVersionsServerUrl + "/",
|
||||||
|
});
|
||||||
|
const res = await getStatistics({
|
||||||
|
url: settings?.optimizedVersionsServerUrl,
|
||||||
|
authHeader: api?.accessToken,
|
||||||
|
deviceId: await getOrSetDeviceId(),
|
||||||
|
});
|
||||||
|
if (res) {
|
||||||
|
toast.success("Connected");
|
||||||
|
} else toast.error("Could not connect");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</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]",
|
||||||
];
|
];
|
||||||
|
|||||||
39
constants/Languages.ts
Normal file
39
constants/Languages.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||||
|
{ label: "English", value: "eng" },
|
||||||
|
{ label: "Spanish", value: "es" },
|
||||||
|
{ label: "Chinese (Mandarin)", value: "zh" },
|
||||||
|
{ label: "Hindi", value: "hi" },
|
||||||
|
{ label: "Arabic", value: "ar" },
|
||||||
|
{ label: "French", value: "fr" },
|
||||||
|
{ label: "Russian", value: "ru" },
|
||||||
|
{ label: "Portuguese", value: "pt" },
|
||||||
|
{ label: "Japanese", value: "ja" },
|
||||||
|
{ label: "German", value: "de" },
|
||||||
|
{ label: "Italian", value: "it" },
|
||||||
|
{ label: "Korean", value: "ko" },
|
||||||
|
{ label: "Turkish", value: "tr" },
|
||||||
|
{ label: "Dutch", value: "nl" },
|
||||||
|
{ label: "Polish", value: "pl" },
|
||||||
|
{ label: "Vietnamese", value: "vi" },
|
||||||
|
{ label: "Thai", value: "th" },
|
||||||
|
{ label: "Indonesian", value: "id" },
|
||||||
|
{ label: "Greek", value: "el" },
|
||||||
|
{ label: "Swedish", value: "sv" },
|
||||||
|
{ label: "Danish", value: "da" },
|
||||||
|
{ label: "Norwegian", value: "no" },
|
||||||
|
{ label: "Finnish", value: "fi" },
|
||||||
|
{ label: "Czech", value: "cs" },
|
||||||
|
{ label: "Hungarian", value: "hu" },
|
||||||
|
{ label: "Romanian", value: "ro" },
|
||||||
|
{ label: "Ukrainian", value: "uk" },
|
||||||
|
{ label: "Hebrew", value: "he" },
|
||||||
|
{ label: "Bengali", value: "bn" },
|
||||||
|
{ label: "Punjabi", value: "pa" },
|
||||||
|
{ label: "Tagalog", value: "tl" },
|
||||||
|
{ label: "Swahili", value: "sw" },
|
||||||
|
{ label: "Malay", value: "ms" },
|
||||||
|
{ label: "Persian", value: "fa" },
|
||||||
|
{ label: "Urdu", value: "ur" },
|
||||||
|
];
|
||||||
3
constants/Values.ts
Normal file
3
constants/Values.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74;
|
||||||
7
eas.json
7
eas.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 9.1.0"
|
"version": ">= 9.1.0",
|
||||||
|
"appVersionSource": "local"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
@@ -21,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.12.0",
|
"channel": "0.16.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.12.0",
|
"channel": "0.16.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 };
|
||||||
|
};
|
||||||
72
hooks/useCreditSkipper.ts
Normal file
72
hooks/useCreditSkipper.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
|
interface CreditTimestamps {
|
||||||
|
Introduction: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
Credits: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreditSkipper = (
|
||||||
|
itemId: string | undefined,
|
||||||
|
currentTime: number,
|
||||||
|
videoRef: React.RefObject<any>
|
||||||
|
) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
|
|
||||||
|
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
||||||
|
queryKey: ["creditTimestamps", itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!itemId) {
|
||||||
|
console.log("No item id");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api?.axiosInstance.get(
|
||||||
|
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res?.status !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res?.data;
|
||||||
|
},
|
||||||
|
enabled: !!itemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creditTimestamps) {
|
||||||
|
setShowSkipCreditButton(
|
||||||
|
currentTime > creditTimestamps.Credits.Start &&
|
||||||
|
currentTime < creditTimestamps.Credits.End
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [creditTimestamps, currentTime]);
|
||||||
|
|
||||||
|
const skipCredit = useCallback(() => {
|
||||||
|
if (!creditTimestamps || !videoRef.current) return;
|
||||||
|
try {
|
||||||
|
videoRef.current.seek(creditTimestamps.Credits.End);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
|
}
|
||||||
|
}, [creditTimestamps, videoRef]);
|
||||||
|
|
||||||
|
return { showSkipCreditButton, skipCredit };
|
||||||
|
};
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for downloading media using the Jellyfin API.
|
|
||||||
*
|
|
||||||
* @param api - The Jellyfin API instance
|
|
||||||
* @param userId - The user ID
|
|
||||||
* @returns An object with download-related functions and state
|
|
||||||
*/
|
|
||||||
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadMedia = useCallback(
|
|
||||||
async (item: BaseItemDto | null): Promise<boolean> => {
|
|
||||||
if (!item?.Id || !api || !userId) {
|
|
||||||
setError("Invalid item or API");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDownloading(true);
|
|
||||||
setError(null);
|
|
||||||
setProgress({ item, progress: 0 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filename = item.Id;
|
|
||||||
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
|
||||||
const url = `${api.basePath}/Items/${item.Id}/File`;
|
|
||||||
|
|
||||||
downloadResumableRef.current = FileSystem.createDownloadResumable(
|
|
||||||
url,
|
|
||||||
fileUri,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
(downloadProgress) => {
|
|
||||||
const currentProgress =
|
|
||||||
downloadProgress.totalBytesWritten /
|
|
||||||
downloadProgress.totalBytesExpectedToWrite;
|
|
||||||
setProgress({ item, progress: currentProgress * 100 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await downloadResumableRef.current.downloadAsync();
|
|
||||||
|
|
||||||
if (!res?.uri) {
|
|
||||||
throw new Error("Download failed: No URI returned");
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateDownloadedFiles(item);
|
|
||||||
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error downloading media:", error);
|
|
||||||
setError("Failed to download media");
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, userId, setProgress],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelDownload = useCallback(async (): Promise<void> => {
|
|
||||||
if (!downloadResumableRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadResumableRef.current.pauseAsync();
|
|
||||||
setIsDownloading(false);
|
|
||||||
setError("Download cancelled");
|
|
||||||
setProgress(null);
|
|
||||||
downloadResumableRef.current = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error cancelling download:", error);
|
|
||||||
setError("Failed to cancel download");
|
|
||||||
}
|
|
||||||
}, [setProgress]);
|
|
||||||
|
|
||||||
return { downloadMedia, isDownloading, error, cancelDownload };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) ?? "[]",
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((file) => file.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
55
hooks/useDownloadedFileOpener.ts
Normal file
55
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// hooks/useFileOpener.ts
|
||||||
|
|
||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export const useFileOpener = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { startDownloadedFilePlayback } = usePlayback();
|
||||||
|
|
||||||
|
const openFile = useCallback(
|
||||||
|
async (item: BaseItemDto) => {
|
||||||
|
const directory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
throw new Error("Document directory is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.Id) {
|
||||||
|
throw new Error("Item ID is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await FileSystem.readDirectoryAsync(directory);
|
||||||
|
for (let f of files) {
|
||||||
|
console.log(f);
|
||||||
|
}
|
||||||
|
const path = item.Id!;
|
||||||
|
const matchingFile = files.find((file) => file.startsWith(path));
|
||||||
|
|
||||||
|
if (!matchingFile) {
|
||||||
|
throw new Error(`No file found for item ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${directory}${matchingFile}`;
|
||||||
|
|
||||||
|
console.log("Opening " + url);
|
||||||
|
|
||||||
|
startDownloadedFilePlayback({
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
router.push("/play");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error opening file:", error);
|
||||||
|
// Handle the error appropriately, e.g., show an error message to the user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[startDownloadedFilePlayback]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { openFile };
|
||||||
|
};
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for managing downloaded files.
|
|
||||||
* @returns An object with functions to delete individual files and all files.
|
|
||||||
*/
|
|
||||||
export const useFiles = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes all downloaded files and clears the download record.
|
|
||||||
*/
|
|
||||||
const deleteAllFiles = async (): Promise<void> => {
|
|
||||||
const directoryUri = FileSystem.documentDirectory;
|
|
||||||
if (!directoryUri) {
|
|
||||||
console.error("Document directory is undefined");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileNames = await FileSystem.readDirectoryAsync(directoryUri);
|
|
||||||
await Promise.all(
|
|
||||||
fileNames.map((item) =>
|
|
||||||
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
|
||||||
idempotent: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await AsyncStorage.removeItem("downloaded_files");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete all files:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a specific file and updates the download record.
|
|
||||||
* @param id - The ID of the file to delete.
|
|
||||||
*/
|
|
||||||
const deleteFile = async (id: string): Promise<void> => {
|
|
||||||
if (!id) {
|
|
||||||
console.error("Invalid file ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FileSystem.deleteAsync(
|
|
||||||
`${FileSystem.documentDirectory}/${id}.mp4`,
|
|
||||||
{ idempotent: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentFiles = await getDownloadedFiles();
|
|
||||||
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
|
|
||||||
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles)
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to delete file with ID ${id}:`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deleteFile, deleteAllFiles };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the list of downloaded files from AsyncStorage.
|
|
||||||
* @returns An array of BaseItemDto objects representing downloaded files.
|
|
||||||
*/
|
|
||||||
async function getDownloadedFiles(): Promise<BaseItemDto[]> {
|
|
||||||
try {
|
|
||||||
const filesJson = await AsyncStorage.getItem("downloaded_files");
|
|
||||||
return filesJson ? JSON.parse(filesJson) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve downloaded files:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,95 @@
|
|||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
adjustToNearBlack,
|
||||||
|
calculateTextColor,
|
||||||
|
isCloseToBlack,
|
||||||
|
itemThemeColorAtom,
|
||||||
|
} from "@/utils/atoms/primaryColor";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { getColors } from "react-native-image-colors";
|
import { getColors } from "react-native-image-colors";
|
||||||
|
|
||||||
export const useImageColors = (
|
/**
|
||||||
uri: string | undefined | null,
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
disabled = false
|
*
|
||||||
) => {
|
* @param item - The BaseItemDto object representing the item.
|
||||||
|
* @param disabled - A boolean flag to disable color extraction.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
|
const source = useMemo(() => {
|
||||||
|
if (!api || !item) return;
|
||||||
|
return getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 80,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (uri) {
|
if (source?.uri) {
|
||||||
getColors(uri, {
|
// Check if colors are already cached in storage
|
||||||
|
const _primary = storage.getString(`${source.uri}-primary`);
|
||||||
|
const _text = storage.getString(`${source.uri}-text`);
|
||||||
|
|
||||||
|
// If colors are cached, use them and exit
|
||||||
|
if (_primary && _text) {
|
||||||
|
console.info("[useImageColors] Using cached colors for performance.");
|
||||||
|
setPrimaryColor({
|
||||||
|
primary: _primary,
|
||||||
|
text: _text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract colors from the image
|
||||||
|
getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: true,
|
cache: true,
|
||||||
key: uri,
|
key: source.uri,
|
||||||
})
|
})
|
||||||
.then((colors) => {
|
.then((colors) => {
|
||||||
let primary: string = "#fff";
|
let primary: string = "#fff";
|
||||||
let average: string = "#fff";
|
let text: string = "#000";
|
||||||
let secondary: string = "#fff";
|
|
||||||
|
|
||||||
|
// Select the appropriate color based on the platform
|
||||||
if (colors.platform === "android") {
|
if (colors.platform === "android") {
|
||||||
primary = colors.dominant;
|
primary = colors.dominant;
|
||||||
average = colors.average;
|
|
||||||
secondary = colors.muted;
|
|
||||||
} else if (colors.platform === "ios") {
|
} else if (colors.platform === "ios") {
|
||||||
primary = colors.primary;
|
primary = colors.primary;
|
||||||
secondary = colors.detail;
|
|
||||||
average = colors.background;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adjust the primary color if it's too close to black
|
||||||
|
if (primary && isCloseToBlack(primary)) {
|
||||||
|
primary = adjustToNearBlack(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the text color based on the primary color
|
||||||
|
if (primary) text = calculateTextColor(primary);
|
||||||
|
|
||||||
setPrimaryColor({
|
setPrimaryColor({
|
||||||
primary,
|
primary,
|
||||||
secondary,
|
text,
|
||||||
average,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cache the colors in storage
|
||||||
|
if (source.uri && primary) {
|
||||||
|
storage.set(`${source.uri}-primary`, primary);
|
||||||
|
storage.set(`${source.uri}-text`, text);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error getting colors", error);
|
console.error("Error getting colors", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [uri, setPrimaryColor, disabled]);
|
}, [source?.uri, setPrimaryColor, disabled]);
|
||||||
};
|
};
|
||||||
|
|||||||
89
hooks/useImageStorage.ts
Normal file
89
hooks/useImageStorage.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
const useImageStorage = () => {
|
||||||
|
const saveBase64Image = useCallback(async (base64: string, key: string) => {
|
||||||
|
try {
|
||||||
|
// Save the base64 string to AsyncStorage
|
||||||
|
storage.set(key, base64);
|
||||||
|
console.log("Image saved successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving image:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
let blob: Blob;
|
||||||
|
try {
|
||||||
|
// Fetch the data from the URL
|
||||||
|
const response = await fetch(url);
|
||||||
|
blob = await response.blob();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error fetching image:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a FileReader instance
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
// Convert blob to base64
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === "string") {
|
||||||
|
// Extract the base64 string (remove the data URL prefix)
|
||||||
|
const base64 = reader.result.split(",")[1];
|
||||||
|
resolve(base64);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to convert image to base64"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveImage = useCallback(
|
||||||
|
async (key?: string | null, imageUrl?: string | null) => {
|
||||||
|
if (!imageUrl || !key) {
|
||||||
|
console.warn("Invalid image URL or key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64Image = await image2Base64(imageUrl);
|
||||||
|
if (!base64Image || base64Image.length === 0) {
|
||||||
|
console.warn("Failed to convert image to base64");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveBase64Image(base64Image, key);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Error saving image:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadImage = useCallback(async (key: string) => {
|
||||||
|
try {
|
||||||
|
// Retrieve the base64 string from AsyncStorage
|
||||||
|
const base64Image = storage.getString(key);
|
||||||
|
if (base64Image !== null) {
|
||||||
|
// Set the loaded image state
|
||||||
|
return `data:image/jpeg;base64,${base64Image}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading image:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { saveImage, loadImage, saveBase64Image, image2Base64 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useImageStorage;
|
||||||
68
hooks/useIntroSkipper.ts
Normal file
68
hooks/useIntroSkipper.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
|
interface IntroTimestamps {
|
||||||
|
EpisodeId: string;
|
||||||
|
HideSkipPromptAt: number;
|
||||||
|
IntroEnd: number;
|
||||||
|
IntroStart: number;
|
||||||
|
ShowSkipPromptAt: number;
|
||||||
|
Valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIntroSkipper = (
|
||||||
|
itemId: string | undefined,
|
||||||
|
currentTime: number,
|
||||||
|
videoRef: React.RefObject<any>
|
||||||
|
) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
|
|
||||||
|
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
||||||
|
queryKey: ["introTimestamps", itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!itemId) {
|
||||||
|
console.log("No item id");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api?.axiosInstance.get(
|
||||||
|
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res?.status !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res?.data;
|
||||||
|
},
|
||||||
|
enabled: !!itemId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introTimestamps) {
|
||||||
|
setShowSkipButton(
|
||||||
|
currentTime > introTimestamps.ShowSkipPromptAt &&
|
||||||
|
currentTime < introTimestamps.HideSkipPromptAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [introTimestamps, currentTime]);
|
||||||
|
|
||||||
|
const skipIntro = useCallback(() => {
|
||||||
|
if (!introTimestamps || !videoRef.current) return;
|
||||||
|
try {
|
||||||
|
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
|
}
|
||||||
|
}, [introTimestamps, videoRef]);
|
||||||
|
|
||||||
|
return { showSkipButton, skipIntro };
|
||||||
|
};
|
||||||
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]);
|
||||||
|
};
|
||||||
@@ -4,9 +4,12 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
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";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -16,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
* @returns An object with remuxing-related functions
|
* @returns An object with remuxing-related functions
|
||||||
*/
|
*/
|
||||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
if (!item.Id || !item.Name) {
|
if (!item.Id || !item.Name) {
|
||||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||||
@@ -28,6 +32,18 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
|
|
||||||
|
toast.success(`Download started for ${item.Name}`, {
|
||||||
|
action: {
|
||||||
|
label: "Go to download",
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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(
|
||||||
@@ -36,7 +52,20 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
setProcesses((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
deviceId: "",
|
||||||
|
inputUrl: "",
|
||||||
|
item,
|
||||||
|
itemId: item.Id,
|
||||||
|
outputPath: "",
|
||||||
|
progress: 0,
|
||||||
|
status: "downloading",
|
||||||
|
timestamp: new Date(),
|
||||||
|
} as JobStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||||
const videoLength =
|
const videoLength =
|
||||||
@@ -51,11 +80,19 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
? Math.floor((processedFrames / totalFrames) * 100)
|
? Math.floor((processedFrames / totalFrames) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
setProgress((prev) =>
|
if (!item.Id) throw new Error("Item is undefined");
|
||||||
prev?.item.Id === item.Id!
|
setProcesses((prev) => {
|
||||||
? { ...prev, progress: percentage, speed }
|
return prev.map((process) => {
|
||||||
: prev
|
if (process.itemId === item.Id) {
|
||||||
);
|
return {
|
||||||
|
...process,
|
||||||
|
progress: percentage,
|
||||||
|
speed: Math.max(speed, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return process;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||||
@@ -65,11 +102,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
const returnCode = await session.getReturnCode();
|
const returnCode = await session.getReturnCode();
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
await updateDownloadedFiles(item);
|
if (!item) throw new Error("Item is undefined");
|
||||||
|
await saveDownloadedItemInfo(item);
|
||||||
|
toast.success("Download completed");
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
||||||
);
|
);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["downloadedItems"],
|
||||||
|
});
|
||||||
resolve();
|
resolve();
|
||||||
} else if (returnCode.isValueError()) {
|
} else if (returnCode.isValueError()) {
|
||||||
writeToLog(
|
writeToLog(
|
||||||
@@ -85,63 +127,35 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
|||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress(null);
|
setProcesses((prev) => {
|
||||||
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to remux:", error);
|
console.error("Failed to remux:", error);
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"ERROR",
|
"ERROR",
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||||
);
|
);
|
||||||
setProgress(null);
|
setProcesses((prev) => {
|
||||||
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
|
});
|
||||||
throw error; // Re-throw the error to propagate it to the caller
|
throw error; // Re-throw the error to propagate it to the caller
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[output, item, setProgress]
|
[output, item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
const cancelRemuxing = useCallback(() => {
|
||||||
FFmpegKit.cancel();
|
FFmpegKit.cancel();
|
||||||
setProgress(null);
|
setProcesses((prev) => {
|
||||||
writeToLog(
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
"INFO",
|
});
|
||||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
|
}, [item.Name]);
|
||||||
);
|
|
||||||
}, [item.Name, setProgress]);
|
|
||||||
|
|
||||||
return { startRemuxing, cancelRemuxing };
|
return { startRemuxing, cancelRemuxing };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`Failed to update downloaded files for item: ${item.Name}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
108
hooks/useTrickplay.ts
Normal file
108
hooks/useTrickplay.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// hooks/useTrickplay.ts
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useRef } from "react";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { SharedValue } from "react-native-reanimated";
|
||||||
|
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
interface TrickplayData {
|
||||||
|
Interval?: number;
|
||||||
|
TileWidth?: number;
|
||||||
|
TileHeight?: number;
|
||||||
|
Height?: number;
|
||||||
|
Width?: number;
|
||||||
|
ThumbnailCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrickplayInfo {
|
||||||
|
resolution: string;
|
||||||
|
aspectRatio: number;
|
||||||
|
data: TrickplayData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrickplayUrl {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTrickplay = (
|
||||||
|
currentlyPlaying?: CurrentlyPlayingState | null
|
||||||
|
) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||||
|
const lastCalculationTime = useRef(0);
|
||||||
|
const throttleDelay = 200; // 200ms throttle
|
||||||
|
|
||||||
|
const trickplayInfo = useMemo(() => {
|
||||||
|
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaSourceId = currentlyPlaying.item.Id;
|
||||||
|
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
|
||||||
|
|
||||||
|
if (!trickplayData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first available resolution
|
||||||
|
const firstResolution = Object.keys(trickplayData)[0];
|
||||||
|
return firstResolution
|
||||||
|
? {
|
||||||
|
resolution: firstResolution,
|
||||||
|
aspectRatio:
|
||||||
|
trickplayData[firstResolution].Width! /
|
||||||
|
trickplayData[firstResolution].Height!,
|
||||||
|
data: trickplayData[firstResolution],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}, [currentlyPlaying]);
|
||||||
|
|
||||||
|
const calculateTrickplayUrl = useCallback(
|
||||||
|
(progress: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCalculationTime.current < throttleDelay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
lastCalculationTime.current = now;
|
||||||
|
|
||||||
|
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, resolution } = trickplayInfo;
|
||||||
|
const { Interval, TileWidth, TileHeight } = data;
|
||||||
|
|
||||||
|
if (!Interval || !TileWidth || !TileHeight || !resolution) {
|
||||||
|
throw new Error("Invalid trickplay data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSecond = Math.max(0, Math.floor(progress / 10000000));
|
||||||
|
|
||||||
|
const cols = TileWidth;
|
||||||
|
const rows = TileHeight;
|
||||||
|
const imagesPerTile = cols * rows;
|
||||||
|
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
|
||||||
|
const tileIndex = Math.floor(imageIndex / imagesPerTile);
|
||||||
|
|
||||||
|
const positionInTile = imageIndex % imagesPerTile;
|
||||||
|
const rowInTile = Math.floor(positionInTile / cols);
|
||||||
|
const colInTile = positionInTile % cols;
|
||||||
|
|
||||||
|
const newTrickPlayUrl = {
|
||||||
|
x: rowInTile,
|
||||||
|
y: colInTile,
|
||||||
|
url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTrickPlayUrl(newTrickPlayUrl);
|
||||||
|
return newTrickPlayUrl;
|
||||||
|
},
|
||||||
|
[trickplayInfo, currentlyPlaying, api]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
|
||||||
|
};
|
||||||
54
package.json
54
package.json
@@ -17,65 +17,71 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.3",
|
||||||
"@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",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
|
||||||
"@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.3",
|
||||||
"@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.54.1",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"@types/lodash": "^4.17.7",
|
"@types/lodash": "^4.17.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"expo": "~51.0.32",
|
"expo": "~51.0.36",
|
||||||
|
"expo-background-fetch": "~12.0.1",
|
||||||
"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.26",
|
"expo-dev-client": "~4.0.27",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.10",
|
"expo-font": "~12.0.10",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
"expo-image": "~1.12.15",
|
"expo-image": "~1.13.0",
|
||||||
"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-network": "~6.0.1",
|
||||||
|
"expo-notifications": "~0.28.18",
|
||||||
"expo-router": "~3.5.23",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
"expo-screen-orientation": "~7.0.5",
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-sensors": "~13.0.9",
|
||||||
"expo-splash-screen": "~0.27.5",
|
"expo-splash-screen": "~0.27.6",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.7",
|
"expo-system-ui": "~3.0.7",
|
||||||
"expo-updates": "~0.25.24",
|
"expo-task-manager": "~11.8.2",
|
||||||
|
"expo-updates": "~0.25.26",
|
||||||
"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.3",
|
"jotai": "^2.10.0",
|
||||||
"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.75.0",
|
||||||
|
"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.18.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-mmkv": "^2.12.2",
|
||||||
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
"react-native-reanimated": "~3.15.0",
|
||||||
|
"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.34.0",
|
||||||
"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.5.0",
|
"react-native-video": "^6.6.3",
|
||||||
"react-native-web": "~0.19.10",
|
"react-native-web": "~0.19.10",
|
||||||
|
"sonner-native": "^0.14.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.3",
|
"use-debounce": "^10.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
@@ -92,5 +98,15 @@
|
|||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native@~0.74.0",
|
||||||
|
"react-native-reanimated@~3.10.0",
|
||||||
|
"react-native-gesture-handler@~2.16.1",
|
||||||
|
"react-native-screens@~3.31.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
plugins/withRNBackgroundDownloader.js
Normal file
48
plugins/withRNBackgroundDownloader.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const { withAppDelegate } = require("@expo/config-plugins");
|
||||||
|
|
||||||
|
function withRNBackgroundDownloader(expoConfig) {
|
||||||
|
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
|
||||||
|
const { modResults: appDelegate } = appDelegateConfig;
|
||||||
|
const appDelegateLines = appDelegate.contents.split("\n");
|
||||||
|
|
||||||
|
// Define the code to be added to AppDelegate.mm
|
||||||
|
const backgroundDownloaderImport =
|
||||||
|
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
|
||||||
|
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
|
||||||
|
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
|
||||||
|
{
|
||||||
|
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Find the index of the AppDelegate import statement
|
||||||
|
const importIndex = appDelegateLines.findIndex((line) =>
|
||||||
|
/^#import "AppDelegate.h"/.test(line)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the index of the last line before the @end statement
|
||||||
|
const endStatementIndex = appDelegateLines.findIndex((line) =>
|
||||||
|
/@end/.test(line)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert the import statement if it's not already present
|
||||||
|
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
|
||||||
|
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the delegate method above the @end statement
|
||||||
|
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
|
||||||
|
appDelegateLines.splice(
|
||||||
|
endStatementIndex,
|
||||||
|
0,
|
||||||
|
backgroundDownloaderDelegate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the contents of the AppDelegate file
|
||||||
|
appDelegate.contents = appDelegateLines.join("\n");
|
||||||
|
|
||||||
|
return appDelegateConfig;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withRNBackgroundDownloader;
|
||||||
560
providers/DownloadProvider.tsx
Normal file
560
providers/DownloadProvider.tsx
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import {
|
||||||
|
cancelAllJobs,
|
||||||
|
cancelJobById,
|
||||||
|
getAllJobsByDeviceId,
|
||||||
|
JobStatus,
|
||||||
|
} from "@/utils/optimize-server";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
checkForExistingDownloads,
|
||||||
|
completeHandler,
|
||||||
|
download,
|
||||||
|
setConfig,
|
||||||
|
} from "@kesha-antonov/react-native-background-downloader";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import {
|
||||||
|
focusManager,
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { AppState, AppStateStatus } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { apiAtom } from "./JellyfinProvider";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
|
|
||||||
|
function onAppStateChange(status: AppStateStatus) {
|
||||||
|
focusManager.setFocused(status === "active");
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadContext = createContext<ReturnType<
|
||||||
|
typeof useDownloadProvider
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
function useDownloadProvider() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const { loadImage, saveImage, image2Base64, saveBase64Image } =
|
||||||
|
useImageStorage();
|
||||||
|
|
||||||
|
const [processes, setProcesses] = useState<JobStatus[]>([]);
|
||||||
|
|
||||||
|
const authHeader = useMemo(() => {
|
||||||
|
return api?.accessToken;
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const { data: downloadedFiles, refetch } = useQuery({
|
||||||
|
queryKey: ["downloadedItems"],
|
||||||
|
queryFn: getAllDownloadedItems,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["jobs"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings?.downloadMethod !== "optimized" ||
|
||||||
|
!url ||
|
||||||
|
!deviceId ||
|
||||||
|
!authHeader
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const jobs = await getAllJobsByDeviceId({
|
||||||
|
deviceId,
|
||||||
|
authHeader,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local downloading processes that are still valid
|
||||||
|
const downloadingProcesses = processes
|
||||||
|
.filter((p) => p.status === "downloading")
|
||||||
|
.filter((p) => jobs.some((j) => j.id === p.id));
|
||||||
|
|
||||||
|
const updatedProcesses = jobs.filter(
|
||||||
|
(j) => !downloadingProcesses.some((p) => p.id === j.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setProcesses([...updatedProcesses, ...downloadingProcesses]);
|
||||||
|
|
||||||
|
// Go though new jobs and compare them to old jobs
|
||||||
|
// if new job is now completed, start download.
|
||||||
|
for (let job of jobs) {
|
||||||
|
const process = processes.find((p) => p.id === job.id);
|
||||||
|
if (
|
||||||
|
process &&
|
||||||
|
process.status === "optimizing" &&
|
||||||
|
job.status === "completed"
|
||||||
|
) {
|
||||||
|
if (settings.autoDownload) {
|
||||||
|
startDownload(job);
|
||||||
|
} else {
|
||||||
|
toast.info(`${job.item.Name} is ready to be downloaded`, {
|
||||||
|
action: {
|
||||||
|
label: "Go to downloads",
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: `${job.item.Name} is ready to be downloaded`,
|
||||||
|
data: {
|
||||||
|
url: `/downloads`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs;
|
||||||
|
},
|
||||||
|
staleTime: 0,
|
||||||
|
refetchInterval: 2000,
|
||||||
|
enabled: settings?.downloadMethod === "optimized",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIfShouldStartDownload = async () => {
|
||||||
|
if (processes.length === 0) return;
|
||||||
|
await checkForExistingDownloads();
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIfShouldStartDownload();
|
||||||
|
}, [settings, processes]);
|
||||||
|
|
||||||
|
const removeProcess = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cancelJobById({
|
||||||
|
authHeader,
|
||||||
|
id,
|
||||||
|
url: settings?.optimizedVersionsServerUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startDownload = useCallback(
|
||||||
|
async (process: JobStatus) => {
|
||||||
|
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||||
|
|
||||||
|
console.log("[0] Setting process to downloading");
|
||||||
|
setProcesses((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === process.id
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
speed: undefined,
|
||||||
|
status: "downloading",
|
||||||
|
progress: 0,
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
isLogsEnabled: true,
|
||||||
|
progressInterval: 500,
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.info(`Download started for ${process.item.Name}`, {
|
||||||
|
action: {
|
||||||
|
label: "Go to downloads",
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
download({
|
||||||
|
id: process.id,
|
||||||
|
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||||
|
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
||||||
|
})
|
||||||
|
.begin(() => {
|
||||||
|
setProcesses((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === process.id
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
speed: undefined,
|
||||||
|
status: "downloading",
|
||||||
|
progress: 0,
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.progress((data) => {
|
||||||
|
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||||
|
console.log("Download progress:", percent);
|
||||||
|
setProcesses((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === process.id
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
speed: undefined,
|
||||||
|
status: "downloading",
|
||||||
|
progress: percent,
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.done(async () => {
|
||||||
|
await saveDownloadedItemInfo(process.item);
|
||||||
|
toast.success(`Download completed for ${process.item.Name}`, {
|
||||||
|
duration: 3000,
|
||||||
|
action: {
|
||||||
|
label: "Go to downloads",
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
completeHandler(process.id);
|
||||||
|
removeProcess(process.id);
|
||||||
|
}, 1000);
|
||||||
|
})
|
||||||
|
.error(async (error) => {
|
||||||
|
removeProcess(process.id);
|
||||||
|
completeHandler(process.id);
|
||||||
|
let errorMsg = "";
|
||||||
|
if (error.errorCode === 1000) {
|
||||||
|
errorMsg = "No space left";
|
||||||
|
}
|
||||||
|
if (error.errorCode === 404) {
|
||||||
|
errorMsg = "File not found on server";
|
||||||
|
}
|
||||||
|
toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
|
||||||
|
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
||||||
|
error,
|
||||||
|
processDetails: {
|
||||||
|
id: process.id,
|
||||||
|
itemName: process.item.Name,
|
||||||
|
itemId: process.item.Id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error("Error details:", {
|
||||||
|
errorCode: error.errorCode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient, settings?.optimizedVersionsServerUrl, authHeader]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startBackgroundDownload = useCallback(
|
||||||
|
async (url: string, item: BaseItemDto, fileExtension: string) => {
|
||||||
|
if (!api || !item.Id || !authHeader)
|
||||||
|
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
const itemImage = getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 90,
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveImage(item.Id, itemImage?.uri);
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
settings?.optimizedVersionsServerUrl + "optimize-version",
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
fileExtension,
|
||||||
|
deviceId,
|
||||||
|
itemId: item.Id,
|
||||||
|
item,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status !== 201) {
|
||||||
|
throw new Error("Failed to start optimization job");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Queued ${item.Name} for optimization`, {
|
||||||
|
action: {
|
||||||
|
label: "Go to download",
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in startBackgroundDownload:", error);
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error("Axios error details:", {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status,
|
||||||
|
headers: error.response?.headers,
|
||||||
|
});
|
||||||
|
toast.error(
|
||||||
|
`Failed to start download for ${item.Name}: ${error.message}`
|
||||||
|
);
|
||||||
|
if (error.response) {
|
||||||
|
toast.error(
|
||||||
|
`Server responded with status ${error.response.status}`
|
||||||
|
);
|
||||||
|
} else if (error.request) {
|
||||||
|
toast.error("No response received from server");
|
||||||
|
} else {
|
||||||
|
toast.error("Error setting up the request");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Non-Axios error:", error);
|
||||||
|
toast.error(
|
||||||
|
`Failed to start download for ${item.Name}: Unexpected error`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteAllFiles = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await deleteLocalFiles();
|
||||||
|
await removeDownloadedItemsFromStorage();
|
||||||
|
await cancelAllServerJobs();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||||
|
toast.success("All files, folders, and jobs deleted successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete all files, folders, and jobs:", error);
|
||||||
|
toast.error("An error occurred while deleting files and jobs");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteLocalFiles = async (): Promise<void> => {
|
||||||
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
if (!baseDirectory) {
|
||||||
|
throw new Error("Base directory not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||||
|
for (const item of dirContents) {
|
||||||
|
const itemPath = `${baseDirectory}${item}`;
|
||||||
|
const itemInfo = await FileSystem.getInfoAsync(itemPath);
|
||||||
|
if (itemInfo.exists) {
|
||||||
|
if (itemInfo.isDirectory) {
|
||||||
|
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
||||||
|
} else {
|
||||||
|
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDownloadedItemsFromStorage = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem("downloadedItems");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Failed to remove downloadedItems from AsyncStorage:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAllServerJobs = async (): Promise<void> => {
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new Error("No auth header available");
|
||||||
|
}
|
||||||
|
if (!settings?.optimizedVersionsServerUrl) {
|
||||||
|
throw new Error("No server URL configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = await getOrSetDeviceId();
|
||||||
|
if (!deviceId) {
|
||||||
|
throw new Error("Failed to get device ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cancelAllJobs({
|
||||||
|
authHeader,
|
||||||
|
url: settings.optimizedVersionsServerUrl,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to cancel all server jobs:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFile = async (id: string): Promise<void> => {
|
||||||
|
if (!id) {
|
||||||
|
console.error("Invalid file ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const directory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
console.error("Document directory not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dirContents = await FileSystem.readDirectoryAsync(directory);
|
||||||
|
|
||||||
|
for (const item of dirContents) {
|
||||||
|
const itemNameWithoutExtension = item.split(".")[0];
|
||||||
|
if (itemNameWithoutExtension === id) {
|
||||||
|
const filePath = `${directory}${item}`;
|
||||||
|
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
||||||
|
console.log(`Successfully deleted file: ${item}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
|
if (downloadedItems) {
|
||||||
|
let items = JSON.parse(downloadedItems);
|
||||||
|
items = items.filter((item: any) => item.Id !== id);
|
||||||
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Successfully deleted file and AsyncStorage entry for ID ${id}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to delete file and AsyncStorage entry for ID ${id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getAllDownloadedItems(): Promise<BaseItemDto[]> {
|
||||||
|
try {
|
||||||
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
|
if (downloadedItems) {
|
||||||
|
return JSON.parse(downloadedItems) as BaseItemDto[];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve downloaded items:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
|
try {
|
||||||
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
|
let items: BaseItemDto[] = downloadedItems
|
||||||
|
? JSON.parse(downloadedItems)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||||
|
if (existingItemIndex !== -1) {
|
||||||
|
items[existingItemIndex] = item;
|
||||||
|
} else {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save downloaded item information:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processes,
|
||||||
|
startBackgroundDownload,
|
||||||
|
downloadedFiles,
|
||||||
|
deleteAllFiles,
|
||||||
|
deleteFile,
|
||||||
|
saveDownloadedItemInfo,
|
||||||
|
removeProcess,
|
||||||
|
setProcesses,
|
||||||
|
startDownload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const downloadProviderValue = useDownloadProvider();
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DownloadContext.Provider value={downloadProviderValue}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</DownloadContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownload() {
|
||||||
|
const context = useContext(DownloadContext);
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useDownload must be used within a DownloadProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -40,17 +40,6 @@ const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
|||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOrSetDeviceId = async () => {
|
|
||||||
let deviceId = await AsyncStorage.getItem("deviceId");
|
|
||||||
|
|
||||||
if (!deviceId) {
|
|
||||||
deviceId = uuid.v4() as string;
|
|
||||||
await AsyncStorage.setItem("deviceId", deviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deviceId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -63,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.12.0" },
|
clientInfo: { name: "Streamyfin", version: "0.16.0" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -97,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.12.0"`,
|
}, DeviceId="${deviceId}", Version="0.16.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -269,10 +258,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
],
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const token = await AsyncStorage.getItem("token");
|
const token = await getTokenFromStoraage();
|
||||||
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
const serverUrl = await getServerUrlFromStorage();
|
||||||
const user = JSON.parse(
|
const user = JSON.parse(
|
||||||
(await AsyncStorage.getItem("user")) as string
|
(await getUserFromStorage()) as string
|
||||||
) as UserDto;
|
) as UserDto;
|
||||||
|
|
||||||
if (serverUrl && token && user.Id && jellyfin) {
|
if (serverUrl && token && user.Id && jellyfin) {
|
||||||
@@ -331,3 +320,26 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
|
|||||||
}
|
}
|
||||||
}, [user, segments, loading]);
|
}, [user, segments, loading]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTokenFromStoraage() {
|
||||||
|
return await AsyncStorage.getItem("token");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserFromStorage() {
|
||||||
|
return await AsyncStorage.getItem("user");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerUrlFromStorage() {
|
||||||
|
return await AsyncStorage.getItem("serverUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrSetDeviceId() {
|
||||||
|
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
deviceId = uuid.v4() as string;
|
||||||
|
await AsyncStorage.setItem("deviceId", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import React, {
|
|||||||
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDeviceId } from "@/utils/device";
|
import { getDeviceId } from "@/utils/device";
|
||||||
|
import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles";
|
||||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||||
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
|
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
|
||||||
@@ -20,13 +21,14 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { OnProgressData, type VideoRef } from "react-native-video";
|
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
|
|
||||||
type CurrentlyPlayingState = {
|
export type CurrentlyPlayingState = {
|
||||||
url: string;
|
url: string;
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
};
|
};
|
||||||
@@ -45,6 +47,8 @@ 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: (
|
||||||
@@ -53,6 +57,7 @@ interface PlaybackContextType {
|
|||||||
startDownloadedFilePlayback: (
|
startDownloadedFilePlayback: (
|
||||||
currentlyPlaying: CurrentlyPlayingState | null
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
) => void;
|
) => void;
|
||||||
|
subtitles: SubtitleTrack[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||||
@@ -63,6 +68,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
@@ -70,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);
|
||||||
|
|
||||||
@@ -105,13 +114,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
|
||||||
setTimeout(() => {
|
|
||||||
presentFullscreenPlayer();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[settings?.openFullScreenVideoPlayerByDefault]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setCurrentlyPlayingState = useCallback(
|
const setCurrentlyPlayingState = useCallback(
|
||||||
@@ -133,17 +137,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
api,
|
api,
|
||||||
itemId: state.item.Id,
|
itemId: state.item.Id,
|
||||||
sessionId: res.data.PlaySessionId,
|
sessionId: res.data.PlaySessionId,
|
||||||
|
deviceProfile: settings?.deviceProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSession(res.data);
|
setSession(res.data);
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
|
||||||
setTimeout(() => {
|
|
||||||
presentFullscreenPlayer();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setCurrentlyPlaying(null);
|
setCurrentlyPlaying(null);
|
||||||
setIsFullscreen(false);
|
setIsFullscreen(false);
|
||||||
@@ -161,11 +160,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
onPress: () => {
|
onPress: () => {
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
|
||||||
setTimeout(() => {
|
|
||||||
presentFullscreenPlayer();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -214,13 +208,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(
|
||||||
@@ -254,7 +250,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]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -331,6 +327,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
} else if (command === "Stop") {
|
} else if (command === "Stop") {
|
||||||
console.log("Command ~ Stop");
|
console.log("Command ~ Stop");
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
|
router.canGoBack() && router.back();
|
||||||
} else if (command === "Mute") {
|
} else if (command === "Mute") {
|
||||||
console.log("Command ~ Mute");
|
console.log("Command ~ Mute");
|
||||||
setVolume(0);
|
setVolume(0);
|
||||||
@@ -352,6 +349,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
<PlaybackContext.Provider
|
<PlaybackContext.Provider
|
||||||
value={{
|
value={{
|
||||||
onProgress,
|
onProgress,
|
||||||
|
isBuffering,
|
||||||
|
setIsBuffering,
|
||||||
progressTicks,
|
progressTicks,
|
||||||
setVolume,
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
@@ -368,6 +367,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
presentFullscreenPlayer,
|
presentFullscreenPlayer,
|
||||||
dismissFullscreenPlayer,
|
dismissFullscreenPlayer,
|
||||||
startDownloadedFilePlayback,
|
startDownloadedFilePlayback,
|
||||||
|
subtitles,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
21
utils/OrientationLockConverter.ts
Normal file
21
utils/OrientationLockConverter.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Orientation, OrientationLock } from "expo-screen-orientation";
|
||||||
|
|
||||||
|
function orientationToOrientationLock(
|
||||||
|
orientation: Orientation
|
||||||
|
): OrientationLock {
|
||||||
|
switch (orientation) {
|
||||||
|
case Orientation.PORTRAIT_UP:
|
||||||
|
return OrientationLock.PORTRAIT_UP;
|
||||||
|
case Orientation.PORTRAIT_DOWN:
|
||||||
|
return OrientationLock.PORTRAIT_DOWN;
|
||||||
|
case Orientation.LANDSCAPE_LEFT:
|
||||||
|
return OrientationLock.LANDSCAPE_LEFT;
|
||||||
|
case Orientation.LANDSCAPE_RIGHT:
|
||||||
|
return OrientationLock.LANDSCAPE_RIGHT;
|
||||||
|
case Orientation.UNKNOWN:
|
||||||
|
default:
|
||||||
|
return OrientationLock.DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default orientationToOrientationLock;
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
export type ProcessItem = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
progress: number;
|
|
||||||
speed?: number;
|
|
||||||
startTime?: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runningProcesses = atom<ProcessItem | null>(null);
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import { atom, useAtom } from "jotai";
|
|||||||
|
|
||||||
interface ThemeColors {
|
interface ThemeColors {
|
||||||
primary: string;
|
primary: string;
|
||||||
secondary: string;
|
|
||||||
average: string;
|
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateTextColor = (backgroundColor: string): string => {
|
export const calculateTextColor = (backgroundColor: string): string => {
|
||||||
// Convert hex to RGB
|
// Convert hex to RGB
|
||||||
const r = parseInt(backgroundColor.slice(1, 3), 16);
|
const r = parseInt(backgroundColor.slice(1, 3), 16);
|
||||||
const g = parseInt(backgroundColor.slice(3, 5), 16);
|
const g = parseInt(backgroundColor.slice(3, 5), 16);
|
||||||
@@ -48,26 +46,22 @@ 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 baseThemeColorAtom = atom<ThemeColors>({
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adjustToNearBlack = (color: string): string => {
|
||||||
|
return "#212121"; // A very dark gray, almost black
|
||||||
|
};
|
||||||
|
|
||||||
|
export const itemThemeColorAtom = atom<ThemeColors>({
|
||||||
primary: "#FFFFFF",
|
primary: "#FFFFFF",
|
||||||
secondary: "#000000",
|
|
||||||
average: "#888888",
|
|
||||||
text: "#000000",
|
text: "#000000",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const itemThemeColorAtom = atom(
|
|
||||||
(get) => get(baseThemeColorAtom),
|
|
||||||
(get, set, update: Partial<ThemeColors>) => {
|
|
||||||
const currentColors = get(baseThemeColorAtom);
|
|
||||||
const newColors = { ...currentColors, ...update };
|
|
||||||
|
|
||||||
// Recalculate text color if primary color changes
|
|
||||||
if (update.average) {
|
|
||||||
newColors.text = calculateTextColor(update.average);
|
|
||||||
}
|
|
||||||
|
|
||||||
set(baseThemeColorAtom, newColors);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);
|
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { atomWithStorage } from "jotai/utils";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
@@ -8,8 +10,9 @@ export interface Job {
|
|||||||
execute: () => void | Promise<void>;
|
execute: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const runningAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const queueAtom = atom<Job[]>([]);
|
export const queueAtom = atom<Job[]>([]);
|
||||||
export const isProcessingAtom = atom(false);
|
|
||||||
|
|
||||||
export const queueActions = {
|
export const queueActions = {
|
||||||
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
|
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
|
||||||
@@ -20,7 +23,7 @@ export const queueActions = {
|
|||||||
processJob: async (
|
processJob: async (
|
||||||
queue: Job[],
|
queue: Job[],
|
||||||
setQueue: (update: Job[]) => void,
|
setQueue: (update: Job[]) => void,
|
||||||
setProcessing: (processing: boolean) => void,
|
setProcessing: (processing: boolean) => void
|
||||||
) => {
|
) => {
|
||||||
const [job, ...rest] = queue;
|
const [job, ...rest] = queue;
|
||||||
setQueue(rest);
|
setQueue(rest);
|
||||||
@@ -28,13 +31,17 @@ export const queueActions = {
|
|||||||
console.info("Processing job", job);
|
console.info("Processing job", job);
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
|
// Excute the function assiociated with the job.
|
||||||
await job.execute();
|
await job.execute();
|
||||||
|
|
||||||
console.info("Job done", job);
|
console.info("Job done", job);
|
||||||
|
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
},
|
},
|
||||||
clear: (
|
clear: (
|
||||||
setQueue: (update: Job[]) => void,
|
setQueue: (update: Job[]) => void,
|
||||||
setProcessing: (processing: boolean) => void,
|
setProcessing: (processing: boolean) => void
|
||||||
) => {
|
) => {
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
@@ -43,12 +50,12 @@ export const queueActions = {
|
|||||||
|
|
||||||
export const useJobProcessor = () => {
|
export const useJobProcessor = () => {
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
|
const [running, setRunning] = useAtom(runningAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queue.length > 0 && !isProcessing) {
|
if (queue.length > 0 && !running) {
|
||||||
console.info("Processing queue", queue);
|
console.info("Processing queue", queue);
|
||||||
queueActions.processJob(queue, setQueue, setProcessing);
|
queueActions.processJob(queue, setQueue, setRunning);
|
||||||
}
|
}
|
||||||
}, [queue, isProcessing, setQueue, setProcessing]);
|
}, [queue, running, setQueue, setRunning]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -37,10 +54,9 @@ export type DefaultLanguageOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Settings = {
|
export 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;
|
||||||
@@ -53,8 +69,13 @@ type Settings = {
|
|||||||
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
defaultSubtitleLanguage: DefaultLanguageOption | null;
|
||||||
defaultAudioLanguage: DefaultLanguageOption | null;
|
defaultAudioLanguage: DefaultLanguageOption | null;
|
||||||
showHomeTitles: boolean;
|
showHomeTitles: boolean;
|
||||||
|
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||||
|
forwardSkipTime: number;
|
||||||
|
rewindSkipTime: number;
|
||||||
|
optimizedVersionsServerUrl?: string | null;
|
||||||
|
downloadMethod: "optimized" | "remux";
|
||||||
|
autoDownload: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* The settings atom is a Jotai atom that stores the user's settings.
|
* The settings atom is a Jotai atom that stores the user's settings.
|
||||||
@@ -67,7 +88,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,
|
||||||
@@ -86,6 +106,12 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
defaultAudioLanguage: null,
|
defaultAudioLanguage: null,
|
||||||
defaultSubtitleLanguage: null,
|
defaultSubtitleLanguage: null,
|
||||||
showHomeTitles: true,
|
showHomeTitles: true,
|
||||||
|
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
|
forwardSkipTime: 30,
|
||||||
|
rewindSkipTime: 10,
|
||||||
|
optimizedVersionsServerUrl: null,
|
||||||
|
downloadMethod: "remux",
|
||||||
|
autoDownload: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
19
utils/bToMb.ts
Normal file
19
utils/bToMb.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Convert bits to megabits or gigabits
|
||||||
|
*
|
||||||
|
* Return nice looking string
|
||||||
|
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||||
|
if (!bits) return "0MB";
|
||||||
|
|
||||||
|
const megabits = bits / 1000000;
|
||||||
|
|
||||||
|
if (megabits < 1000) {
|
||||||
|
return Math.round(megabits) + "MB";
|
||||||
|
} else {
|
||||||
|
const gigabits = megabits / 1000;
|
||||||
|
return gigabits.toFixed(1) + "GB";
|
||||||
|
}
|
||||||
|
}
|
||||||
23
utils/background-tasks.ts
Normal file
23
utils/background-tasks.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
|
|
||||||
|
export const BACKGROUND_FETCH_TASK = "background-fetch";
|
||||||
|
|
||||||
|
export async function registerBackgroundFetchAsync() {
|
||||||
|
try {
|
||||||
|
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
|
||||||
|
minimumInterval: 60 * 1, // 1 minutes
|
||||||
|
stopOnTerminate: false, // android only,
|
||||||
|
startOnBoot: false, // android only
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error registering background fetch task", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterBackgroundFetchAsync() {
|
||||||
|
try {
|
||||||
|
BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error unregistering background fetch task", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,11 +104,17 @@ 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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!url) throw new Error("No url");
|
if (!url) throw new Error("No url");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
mediaSource.VideoType,
|
||||||
|
mediaSource.Container,
|
||||||
|
mediaSource.TranscodingContainer,
|
||||||
|
mediaSource.TranscodingSubProtocol
|
||||||
|
);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
import { postCapabilities } from "../session/capabilities";
|
import { postCapabilities } from "../session/capabilities";
|
||||||
|
import { Settings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface ReportPlaybackProgressParams {
|
interface ReportPlaybackProgressParams {
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
@@ -8,6 +9,7 @@ interface ReportPlaybackProgressParams {
|
|||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
positionTicks?: number | null;
|
positionTicks?: number | null;
|
||||||
IsPaused?: boolean;
|
IsPaused?: boolean;
|
||||||
|
deviceProfile?: Settings["deviceProfile"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +24,7 @@ export const reportPlaybackProgress = async ({
|
|||||||
itemId,
|
itemId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
IsPaused = false,
|
IsPaused = false,
|
||||||
|
deviceProfile,
|
||||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||||
return;
|
return;
|
||||||
@@ -34,6 +37,7 @@ export const reportPlaybackProgress = async ({
|
|||||||
api,
|
api,
|
||||||
itemId,
|
itemId,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
deviceProfile,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post capabilities.", error);
|
console.error("Failed to post capabilities.", error);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import { Settings } from "@/utils/atoms/settings";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import native from "@/utils/profiles/native";
|
||||||
|
import old from "@/utils/profiles/old";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import {
|
|
||||||
SessionApi,
|
|
||||||
SessionApiPostCapabilitiesRequest,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { AxiosError, AxiosResponse } from "axios";
|
import { AxiosError, AxiosResponse } from "axios";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
interface PostCapabilitiesParams {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
itemId: string | null | undefined;
|
itemId: string | null | undefined;
|
||||||
sessionId: string | null | undefined;
|
sessionId: string | null | undefined;
|
||||||
|
deviceProfile: Settings["deviceProfile"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,16 +24,26 @@ export const postCapabilities = async ({
|
|||||||
api,
|
api,
|
||||||
itemId,
|
itemId,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
deviceProfile,
|
||||||
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||||
if (!api || !itemId || !sessionId) {
|
if (!api || !itemId || !sessionId) {
|
||||||
throw new Error("Missing parameters for marking item as not played");
|
throw new Error("Missing parameters for marking item as not played");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let profile: any = ios;
|
||||||
|
|
||||||
|
if (deviceProfile === "Native") {
|
||||||
|
profile = native;
|
||||||
|
}
|
||||||
|
if (deviceProfile === "Old") {
|
||||||
|
profile = old;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const d = api.axiosInstance.post(
|
const d = api.axiosInstance.post(
|
||||||
api.basePath + "/Sessions/Capabilities/Full",
|
api.basePath + "/Sessions/Capabilities/Full",
|
||||||
{
|
{
|
||||||
playableMediaTypes: ["Audio", "Video", "Audio"],
|
playableMediaTypes: ["Audio", "Video"],
|
||||||
supportedCommands: [
|
supportedCommands: [
|
||||||
"PlayState",
|
"PlayState",
|
||||||
"Play",
|
"Play",
|
||||||
@@ -45,6 +56,7 @@ export const postCapabilities = async ({
|
|||||||
],
|
],
|
||||||
supportsMediaControl: true,
|
supportsMediaControl: true,
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
|
DeviceProfile: profile,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
|
|||||||
3
utils/mmkv.ts
Normal file
3
utils/mmkv.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
|
export const storage = new MMKV();
|
||||||
154
utils/optimize-server.ts
Normal file
154
utils/optimize-server.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface IJobInput {
|
||||||
|
deviceId?: string | null;
|
||||||
|
authHeader?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobStatus {
|
||||||
|
id: string;
|
||||||
|
status:
|
||||||
|
| "queued"
|
||||||
|
| "optimizing"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled"
|
||||||
|
| "downloading";
|
||||||
|
progress: number;
|
||||||
|
outputPath: string;
|
||||||
|
inputUrl: string;
|
||||||
|
deviceId: string;
|
||||||
|
itemId: string;
|
||||||
|
item: Partial<BaseItemDto>;
|
||||||
|
speed?: number;
|
||||||
|
timestamp: Date;
|
||||||
|
base64Image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all jobs for a specific device.
|
||||||
|
*
|
||||||
|
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
|
||||||
|
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
|
||||||
|
* @param {string} params.authHeader - The authorization header for the API request.
|
||||||
|
* @param {string} params.url - The base URL for the API endpoint.
|
||||||
|
*
|
||||||
|
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
|
||||||
|
*
|
||||||
|
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
|
||||||
|
*/
|
||||||
|
export async function getAllJobsByDeviceId({
|
||||||
|
deviceId,
|
||||||
|
authHeader,
|
||||||
|
url,
|
||||||
|
}: IJobInput): Promise<JobStatus[]> {
|
||||||
|
const statusResponse = await axios.get(`${url}all-jobs`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
deviceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (statusResponse.status !== 200) {
|
||||||
|
console.error(
|
||||||
|
statusResponse.status,
|
||||||
|
statusResponse.data,
|
||||||
|
statusResponse.statusText
|
||||||
|
);
|
||||||
|
throw new Error("Failed to fetch job status");
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusResponse.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICancelJob {
|
||||||
|
authHeader: string;
|
||||||
|
url: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelJobById({
|
||||||
|
authHeader,
|
||||||
|
url,
|
||||||
|
id,
|
||||||
|
}: ICancelJob): Promise<boolean> {
|
||||||
|
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (statusResponse.status !== 200) {
|
||||||
|
throw new Error("Failed to cancel process");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
||||||
|
if (!deviceId) return false;
|
||||||
|
if (!authHeader) return false;
|
||||||
|
if (!url) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getAllJobsByDeviceId({
|
||||||
|
deviceId,
|
||||||
|
authHeader,
|
||||||
|
url,
|
||||||
|
}).then((jobs) => {
|
||||||
|
jobs.forEach((job) => {
|
||||||
|
cancelJobById({
|
||||||
|
authHeader,
|
||||||
|
url,
|
||||||
|
id: job.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches statistics for a specific device.
|
||||||
|
*
|
||||||
|
* @param {IJobInput} params - The parameters for the API request.
|
||||||
|
* @param {string} params.deviceId - The ID of the device to fetch statistics for.
|
||||||
|
* @param {string} params.authHeader - The authorization header for the API request.
|
||||||
|
* @param {string} params.url - The base URL for the API endpoint.
|
||||||
|
*
|
||||||
|
* @returns {Promise<any | null>} A promise that resolves to the statistics data or null if the request fails.
|
||||||
|
*
|
||||||
|
* @throws {Error} Throws an error if any required parameter is missing.
|
||||||
|
*/
|
||||||
|
export async function getStatistics({
|
||||||
|
authHeader,
|
||||||
|
url,
|
||||||
|
deviceId,
|
||||||
|
}: IJobInput): Promise<any | null> {
|
||||||
|
if (!deviceId || !authHeader || !url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusResponse = await axios.get(`${url}statistics`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
deviceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return statusResponse.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch statistics:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
utils/secondsToTicks.ts
Normal file
5
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// seconds to ticks util
|
||||||
|
|
||||||
|
export function secondsToTicks(seconds: number): number {
|
||||||
|
return seconds * 10000000;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
|
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
|
||||||
*/
|
*/
|
||||||
export const runtimeTicksToMinutes = (
|
export const runtimeTicksToMinutes = (
|
||||||
ticks: number | null | undefined,
|
ticks: number | null | undefined
|
||||||
): string => {
|
): string => {
|
||||||
if (!ticks) return "0h 0m";
|
if (!ticks) return "0h 0m";
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export const runtimeTicksToMinutes = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const runtimeTicksToSeconds = (
|
export const runtimeTicksToSeconds = (
|
||||||
ticks: number | null | undefined,
|
ticks: number | null | undefined
|
||||||
): string => {
|
): string => {
|
||||||
if (!ticks) return "0h 0m";
|
if (!ticks) return "0h 0m";
|
||||||
|
|
||||||
@@ -34,3 +34,37 @@ export const runtimeTicksToSeconds = (
|
|||||||
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
else return `${minutes}m ${seconds}s`;
|
else return `${minutes}m ${seconds}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatTimeString = (
|
||||||
|
t: number | null | undefined,
|
||||||
|
tick = false
|
||||||
|
): string => {
|
||||||
|
if (t === null || t === undefined) return "0:00";
|
||||||
|
|
||||||
|
let seconds = t;
|
||||||
|
if (tick) {
|
||||||
|
seconds = Math.floor(t / 10000000); // Convert ticks to seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds < 0) return "0:00";
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${remainingSeconds}s`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secondsToTicks = (seconds?: number | undefined) => {
|
||||||
|
if (!seconds) return 0;
|
||||||
|
return seconds * 10000000;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ticksToSeconds = (ticks?: number | undefined) => {
|
||||||
|
if (!ticks) return 0;
|
||||||
|
return ticks / 10000000;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user