Compare commits
162 Commits
feat/syncp
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
317e719460 | ||
|
|
ec0843d737 | ||
|
|
a5b4f6cc78 | ||
|
|
4b60de4d43 | ||
|
|
aa56749402 | ||
|
|
d6f02bd970 | ||
|
|
862e783de1 | ||
|
|
0233862fc1 | ||
|
|
cc242a971f | ||
|
|
4fc3044838 | ||
|
|
df6cd17099 | ||
|
|
5e8a0a9fa9 | ||
|
|
005938a421 | ||
|
|
81aafa26d4 | ||
|
|
0080874213 | ||
|
|
aa89c66e6e | ||
|
|
b1e2020b43 | ||
|
|
5ab53738d5 | ||
|
|
95de03f8b1 | ||
|
|
48570489d5 | ||
|
|
2c14a18e53 | ||
|
|
200ccc6070 | ||
|
|
1c20a3453f | ||
|
|
bf1efd7ca2 | ||
|
|
387add4c83 | ||
|
|
d064622055 | ||
|
|
a04296f395 | ||
|
|
eb11b928af | ||
|
|
4fd67091ea | ||
|
|
57c911cc94 | ||
|
|
61255e6dc4 | ||
|
|
d9037e72f0 | ||
|
|
b25cdce702 | ||
|
|
a85d5bbc92 | ||
|
|
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,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: '❌ bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: '✨ enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
11
README.md
@@ -13,7 +13,8 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||
- 🚀 **Skp intro / credits support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 📺 **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.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
@@ -61,7 +62,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
||||
## Get it now
|
||||
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +136,7 @@ Key points of the MPL-2.0:
|
||||
|
||||
## 🌐 Connect with Us
|
||||
|
||||
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
|
||||
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
|
||||
|
||||
If you have questions or need support, feel free to reach out:
|
||||
|
||||
@@ -153,3 +154,7 @@ I'd like to thank the following people and projects for their contributions to S
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
||||
|
||||
17
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.12.0",
|
||||
"version": "0.17.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -10,7 +10,7 @@
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#29164B"
|
||||
"backgroundColor": "#2E2E2E"
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
@@ -19,7 +19,7 @@
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||
"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.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
@@ -33,9 +33,9 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 36,
|
||||
"versionCode": 43,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
@@ -43,11 +43,6 @@
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
@@ -82,7 +77,7 @@
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "14.0"
|
||||
"deploymentTarget": "15.6"
|
||||
},
|
||||
"android": {
|
||||
"android": {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -18,18 +20,6 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : 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: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast />
|
||||
@@ -37,10 +27,9 @@ export default function IndexLayout() {
|
||||
onPress={() => {
|
||||
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} />
|
||||
</View>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
@@ -61,6 +50,16 @@ export default function IndexLayout() {
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
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 { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedFiles } = useDownload();
|
||||
|
||||
const { data: downloadedFiles, isLoading } = useQuery({
|
||||
queryKey: ["downloaded_files", process?.item.Id],
|
||||
queryFn: async () =>
|
||||
JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
) as BaseItemDto[],
|
||||
staleTime: 0,
|
||||
});
|
||||
const [settings] = useSettings();
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||
@@ -43,27 +34,8 @@ const downloads: React.FC = () => {
|
||||
return Object.values(series);
|
||||
}, [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();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -72,121 +44,80 @@ const downloads: React.FC = () => {
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="px-4 py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4">
|
||||
<View>
|
||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
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"
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||
</View>
|
||||
<View className="py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||
{settings?.downloadMethod === "remux" && (
|
||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Queue</Text>
|
||||
<Text className="text-xs opacity-70 text-red-600">
|
||||
Queue and downloads will be lost on app restart
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 mt-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setQueue((prev) => prev.filter((i) => i.id !== q.id));
|
||||
}}
|
||||
onPress={() =>
|
||||
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>
|
||||
<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
|
||||
onPress={() => {
|
||||
FFmpegKit.cancel();
|
||||
setProcess(null);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
className={`
|
||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
||||
`}
|
||||
style={{
|
||||
width: process.progress
|
||||
? `${Math.max(5, process.progress)}%`
|
||||
: "5%",
|
||||
}}
|
||||
></View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text className="opacity-50">No active downloads</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className="opacity-50">No items in queue</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2">
|
||||
<Text className="text-2xl font-bold">Movies</Text>
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">Movies</Text>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
{movies?.map((item: BaseItemDto) => (
|
||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||
<MovieCard item={item} />
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{movies?.map((item: BaseItemDto) => (
|
||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||
<MovieCard item={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||
<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>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
@@ -4,11 +4,17 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
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 {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
@@ -17,33 +23,32 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type BaseSection = {
|
||||
title: string;
|
||||
queryKey: (string | undefined)[];
|
||||
};
|
||||
|
||||
type ScrollingCollectionListSection = BaseSection & {
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
queryFn: () => Promise<BaseItemDto[]>;
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
type MediaListSection = BaseSection & {
|
||||
type MediaListSection = {
|
||||
type: "MediaListSection";
|
||||
queryFn: () => Promise<BaseItemDto>;
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||
@@ -52,8 +57,8 @@ export default function index() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
@@ -61,6 +66,29 @@ export default function index() {
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||
|
||||
const { downloadedFiles } = useDownload();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Feather
|
||||
name="download"
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={22}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [downloadedFiles, navigation, router]);
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
setLoadingRetry(true);
|
||||
const state = await NetInfo.fetch();
|
||||
@@ -89,7 +117,7 @@ export default function index() {
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["userViews", user?.Id],
|
||||
queryKey: ["home", "userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
@@ -110,7 +138,7 @@ export default function index() {
|
||||
isError: e2,
|
||||
isLoading: l2,
|
||||
} = useQuery({
|
||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
@@ -128,43 +156,99 @@ export default function index() {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const movieCollectionId = useMemo(() => {
|
||||
return userViews?.find((c) => c.CollectionType === "movies")?.Id;
|
||||
}, [userViews]);
|
||||
|
||||
const tvShowCollectionId = useMemo(() => {
|
||||
return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
|
||||
const collections = useMemo(() => {
|
||||
const allow = ["movies", "tvshows"];
|
||||
return (
|
||||
userViews?.filter(
|
||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
||||
) || []
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await queryClient.refetchQueries({ queryKey: ["userViews"] });
|
||||
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.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
refetchType: "all",
|
||||
type: "all",
|
||||
exact: false,
|
||||
});
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["sf_carousel"],
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
refetchType: "all",
|
||||
type: "all",
|
||||
exact: false,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["item"],
|
||||
refetchType: "all",
|
||||
type: "all",
|
||||
exact: false,
|
||||
});
|
||||
setLoading(false);
|
||||
}, [queryClient, user?.Id]);
|
||||
}, [queryClient]);
|
||||
|
||||
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(() => {
|
||||
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 = [
|
||||
"home",
|
||||
"recentlyAddedIn" + c.CollectionType,
|
||||
user?.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: "Continue Watching",
|
||||
queryKey: ["resumeItems", user.Id],
|
||||
queryKey: ["home", "resumeItems", user.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
@@ -172,7 +256,7 @@ export default function index() {
|
||||
},
|
||||
{
|
||||
title: "Next Up",
|
||||
queryKey: ["nextUp-all", user?.Id],
|
||||
queryKey: ["home", "nextUp-all", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
@@ -185,52 +269,20 @@ export default function index() {
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
...latestMediaViews,
|
||||
...(mediaListCollections?.map(
|
||||
(ml) =>
|
||||
({
|
||||
title: ml.Name || "",
|
||||
queryKey: ["mediaList", ml.Id],
|
||||
title: ml.Name,
|
||||
queryKey: ["home", "mediaList", ml.Id!],
|
||||
queryFn: async () => ml,
|
||||
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",
|
||||
queryKey: ["suggestedMovies", user?.Id],
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
@@ -245,7 +297,7 @@ export default function index() {
|
||||
},
|
||||
{
|
||||
title: "Suggested Episodes",
|
||||
queryKey: ["suggestedEpisodes", user?.Id],
|
||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
@@ -265,13 +317,7 @@ export default function index() {
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
movieCollectionId,
|
||||
tvShowCollectionId,
|
||||
mediaListCollections,
|
||||
]);
|
||||
}, [api, user?.Id, collections, mediaListCollections]);
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
@@ -341,14 +387,17 @@ export default function index() {
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
className="flex flex-col pt-4 pb-24 gap-y-4"
|
||||
>
|
||||
<View className="flex flex-col space-y-4">
|
||||
<LargeMovieCarousel />
|
||||
|
||||
{sections.map((section, index) => {
|
||||
|
||||
@@ -2,18 +2,20 @@ import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs, readFromLog } from "@/utils/log";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
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 { toast } from "sonner-native";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles } = useFiles();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -26,6 +28,35 @@ export default function settings() {
|
||||
|
||||
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 (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -35,43 +66,68 @@ export default function settings() {
|
||||
}}
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
{/* <Button
|
||||
onPress={() => {
|
||||
registerBackgroundFetchAsync();
|
||||
}}
|
||||
>
|
||||
registerBackgroundFetchAsync
|
||||
</Button> */}
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||
<Text className="font-bold text-lg mb-2">User Info</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="Server" subTitle={api?.basePath} />
|
||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||
Authorize
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<SettingToggles />
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all logs
|
||||
</Button>
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Error
|
||||
);
|
||||
toast.error("Error deleting files");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||
|
||||
@@ -50,7 +50,7 @@ const page: React.FC = () => {
|
||||
userId: user.Id,
|
||||
personIds: [actorId],
|
||||
startIndex: pageParam,
|
||||
limit: 8,
|
||||
limit: 16,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
recursive: true,
|
||||
|
||||
@@ -34,8 +34,6 @@ import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
@@ -169,7 +167,7 @@ const page: React.FC = () => {
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<MemoizedTouchableItemRouter
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -193,7 +191,7 @@ const page: React.FC = () => {
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</MemoizedTouchableItemRouter>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
@@ -1,13 +1,95 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
|
||||
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
const res = await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: id,
|
||||
});
|
||||
|
||||
return memoizedContent;
|
||||
return res;
|
||||
},
|
||||
enabled: !!id && !!api,
|
||||
staleTime: 60 * 1000 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
} else {
|
||||
fadeIn(() => {});
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||
<Text>Could not load item</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-1 relative">
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
style={[animatedStyle]}
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
>
|
||||
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Page);
|
||||
export default Page;
|
||||
|
||||
49
app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
export const Tab = withLayoutContext<
|
||||
MaterialTopTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
MaterialTopTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
<Tab
|
||||
initialRouteName="programs"
|
||||
keyboardDismissMode="none"
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
tabBarItemStyle: {
|
||||
width: 100,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: "black" },
|
||||
animationEnabled: true,
|
||||
lazy: true,
|
||||
swipeEnabled: true,
|
||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="programs" />
|
||||
<Tab.Screen name="guide" />
|
||||
<Tab.Screen name="channels" />
|
||||
<Tab.Screen name="recordings" />
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: 0,
|
||||
limit: 500,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="flex flex-1">
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className="flex flex-row items-center px-4 mb-2">
|
||||
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
||||
<ItemImage
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
width: 60,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
168
app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Button, Dimensions, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [date, setDate] = useState<Date>(new Date());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: guideInfo } = useQuery({
|
||||
queryKey: ["livetv", "guideInfo"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels", currentPage],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: programs } = useQuery({
|
||||
queryKey: ["livetv", "programs", date, currentPage],
|
||||
queryFn: async () => {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const now = new Date();
|
||||
const isToday = startOfDay.toDateString() === now.toDateString();
|
||||
|
||||
const res = await getLiveTvApi(api!).getPrograms({
|
||||
getProgramsDto: {
|
||||
MaxStartDate: endOfDay.toISOString(),
|
||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||
Boolean
|
||||
) as string[],
|
||||
ImageTypeLimit: 1,
|
||||
EnableImages: false,
|
||||
SortBy: ["StartDate"],
|
||||
EnableTotalRecordCount: false,
|
||||
EnableUserData: false,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!channels,
|
||||
});
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
||||
<Button
|
||||
title="Previous"
|
||||
onPress={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
/>
|
||||
<Button
|
||||
title="Next"
|
||||
onPress={handleNextPage}
|
||||
disabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-col w-[64px]">
|
||||
<View
|
||||
style={{
|
||||
height: HOUR_HEIGHT,
|
||||
}}
|
||||
className="bg-neutral-800"
|
||||
></View>
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
||||
<ItemImage
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
item={c}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={{
|
||||
width: screenWidth - 64,
|
||||
}}
|
||||
horizontal
|
||||
scrollEnabled
|
||||
onScroll={(e) => {
|
||||
setScrollX(e.nativeEvent.contentOffset.x);
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<HourHeader height={HOUR_HEIGHT} />
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<MemoizedLiveTVGuideRow
|
||||
channel={c}
|
||||
programs={programs?.Items}
|
||||
key={c.Id}
|
||||
scrollX={scrollX}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
150
app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
View
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={"On now"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||
userId: user?.Id,
|
||||
isAiring: true,
|
||||
limit: 24,
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "shows"]}
|
||||
title={"Shows"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: false,
|
||||
isSeries: true,
|
||||
isSports: false,
|
||||
isNews: false,
|
||||
isKids: false,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "movies"]}
|
||||
title={"Movies"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "sports"]}
|
||||
title={"Sports"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isSports: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "kids"]}
|
||||
title={"For Kids"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isKids: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "news"]}
|
||||
title={"News"}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isNews: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
return (
|
||||
<View className="flex items-center justify-center h-full -mt-12">
|
||||
<Text>Coming soon</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
@@ -20,8 +21,6 @@ const page: React.FC = () => {
|
||||
seasonIndex: string;
|
||||
};
|
||||
|
||||
console.log("seasonIndex", seasonIndex);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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 { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
@@ -65,13 +69,11 @@ const Page = () => {
|
||||
useEffect(() => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sop) {
|
||||
console.log("getSortOrderPreference ~", sop, libraryId);
|
||||
_setSortOrder([sop]);
|
||||
} else {
|
||||
_setSortOrder([SortOrderOption.Ascending]);
|
||||
}
|
||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||
console.log("getSortByPreference ~", obp, libraryId);
|
||||
if (obp) {
|
||||
_setSortBy([obp]);
|
||||
} else {
|
||||
@@ -83,7 +85,6 @@ const Page = () => {
|
||||
(sortBy: SortByOption[]) => {
|
||||
const sop = getSortByPreference(libraryId, sortByPreference);
|
||||
if (sortBy[0] !== sop) {
|
||||
console.log("setSortByPreference ~", sortBy[0], libraryId);
|
||||
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
|
||||
}
|
||||
_setSortBy(sortBy);
|
||||
@@ -95,7 +96,6 @@ const Page = () => {
|
||||
(sortOrder: SortOrderOption[]) => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sortOrder[0] !== sop) {
|
||||
console.log("setSortOrderPreference ~", sortOrder[0], libraryId);
|
||||
setOderByPreference({
|
||||
...sortOrderPreference,
|
||||
[libraryId]: sortOrder[0],
|
||||
@@ -128,6 +128,13 @@ const Page = () => {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: library?.Name || "",
|
||||
});
|
||||
}, [library]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
|
||||
@@ -195,6 +195,16 @@ export default function IndexLayout() {
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ export default function SearchLayout() {
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Loader } from "@/components/Loader";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
@@ -225,9 +226,13 @@ export default function search() {
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
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" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
@@ -250,165 +255,125 @@ export default function search() {
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/series/${item.Id}`)}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
onPress={() => router.push(`/collections/${item.Id}`)}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={albums?.map((m) => m.Id!)}
|
||||
header="Albums"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={songs?.map((m) => m.Id!)}
|
||||
header="Songs"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
@@ -445,7 +410,7 @@ export default function search() {
|
||||
|
||||
type Props = {
|
||||
ids?: string[] | null;
|
||||
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
||||
renderItem: (item: BaseItemDto) => React.ReactNode;
|
||||
header?: string;
|
||||
};
|
||||
|
||||
@@ -483,8 +448,14 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
|
||||
{renderItem(data)}
|
||||
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
className="px-4 mb-2"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
{data.map((item) => renderItem(item))}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
308
app/(auth)/play-music.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const poster = usePoster(playSettings, api);
|
||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
console.log("togglePlay");
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
console.log("stop");
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
}, [videoRef]);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStart({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = data.currentTime * 10000000;
|
||||
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item.Id,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
},
|
||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<StatusBar hidden />
|
||||
|
||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
||||
<Image
|
||||
source={poster}
|
||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
className="absolute z-0 h-full w-full opacity-0"
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={() => {}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Controls
|
||||
item={playSettings.item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
enableTrickplay={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!playSettings?.item || !api) return undefined;
|
||||
return playSettings.item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: playSettings.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [playSettings?.item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: playUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playSettings, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
180
app/(auth)/play-offline-video.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl } = usePlaySettings();
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const videoSource = useVideoSource(playSettings, api, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
const onProgress = useCallback(async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
}, []);
|
||||
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<StatusBar hidden />
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
className="absolute z-0 h-full w-full"
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={() => {}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Controls
|
||||
item={playSettings.item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = 0;
|
||||
|
||||
return {
|
||||
uri: playUrl,
|
||||
isNetwork: false,
|
||||
startPosition,
|
||||
metadata: {
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playSettings, api]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
332
app/(auth)/play-video.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
VideoRef,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
} from "react-native-video";
|
||||
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const poster = usePoster(playSettings, api);
|
||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
}, [videoRef]);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStart({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = data.currentTime * 10000000;
|
||||
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item.Id,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
},
|
||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
const selectedSubtitleTrack = useMemo(() => {
|
||||
const a = playSettings?.mediaSource?.MediaStreams?.find(
|
||||
(s) => s.Index === playSettings.subtitleIndex
|
||||
);
|
||||
console.log(a);
|
||||
return a;
|
||||
}, [playSettings]);
|
||||
|
||||
const [hlsSubTracks, setHlsSubTracks] = useState<
|
||||
{
|
||||
index: number;
|
||||
language?: string | undefined;
|
||||
selected?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
type: any;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const selectedTextTrack = useMemo(() => {
|
||||
for (let st of hlsSubTracks) {
|
||||
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
|
||||
return {
|
||||
type: SelectedTrackType.TITLE,
|
||||
value: selectedSubtitleTrack?.DisplayTitle ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [hlsSubTracks]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<StatusBar hidden />
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
className="absolute z-0 h-full w-full"
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={() => {}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
onTextTracks={(data) => {
|
||||
console.log("onTextTracks ~", data);
|
||||
setHlsSubTracks(data.textTracks as any);
|
||||
}}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Controls
|
||||
item={playSettings.item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!playSettings?.item || !api) return undefined;
|
||||
return playSettings.item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: playSettings.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [playSettings?.item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: playUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playSettings, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
354
app/_layout.tsx
@@ -1,27 +1,225 @@
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStoraage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-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 { 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 { 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 SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import * as Linking from "expo-linking";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
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") {
|
||||
writeToLog("INFO", "Notification permissions granted.");
|
||||
console.log("Notification permissions granted.");
|
||||
} else {
|
||||
writeToLog("ERROR", "Notification permissions denied.");
|
||||
console.log("Notification permissions denied.");
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
|
||||
} else {
|
||||
console.log("Already asked for notification permissions before.");
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Error checking/requesting notification permissions:",
|
||||
error
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
@@ -49,12 +247,13 @@ function Layout() {
|
||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||
|
||||
useKeepAwake();
|
||||
useNotificationObserver();
|
||||
|
||||
const queryClientRef = useRef<QueryClient>(
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
@@ -64,6 +263,10 @@ function Layout() {
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate === true)
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
@@ -73,10 +276,28 @@ function Layout() {
|
||||
);
|
||||
}, [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(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
console.log(event.orientationInfo.orientation);
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
@@ -99,35 +320,98 @@ function Layout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaybackProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
<ActionSheetProvider>
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-video"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-offline-video"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-music"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
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
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
</JobQueueProvider>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
</ActionSheetProvider>
|
||||
</QueryClientProvider>
|
||||
</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) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
}
|
||||
|
||||
112
app/login.tsx
@@ -3,6 +3,8 @@ import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
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 { useAtom } from "jotai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
@@ -33,6 +35,7 @@ const Login: React.FC = () => {
|
||||
} = params as { apiUrl: string; username: string; password: string };
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
@@ -44,6 +47,8 @@ const Login: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
(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) {
|
||||
setServer({
|
||||
address: _apiUrl,
|
||||
@@ -79,12 +84,93 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (url: string) => {
|
||||
if (!url.startsWith("http")) {
|
||||
Alert.alert("Error", "URL needs to start with http or https.");
|
||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
setServer({ address: url.trim() });
|
||||
|
||||
setServer({ address: result });
|
||||
};
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
@@ -113,7 +199,9 @@ const Login: React.FC = () => {
|
||||
<View></View>
|
||||
<View>
|
||||
<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">
|
||||
Server: {api.basePath}
|
||||
</Text>
|
||||
@@ -121,7 +209,6 @@ const Login: React.FC = () => {
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
@@ -138,9 +225,6 @@ const Login: React.FC = () => {
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Log in to any user account
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
@@ -218,11 +302,13 @@ const Login: React.FC = () => {
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="opacity-30">
|
||||
Server URL requires http or https
|
||||
</Text>
|
||||
</View>
|
||||
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => await handleConnect(serverURL)}
|
||||
className="mb-2"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
BIN
assets/images/adaptive_icon.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 49 KiB |
@@ -1,20 +1,13 @@
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
selected?: number | null;
|
||||
}
|
||||
|
||||
export const AudioTrackSelector: React.FC<Props> = ({
|
||||
@@ -23,8 +16,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
@@ -35,24 +26,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultAudioIndex = audioStreams?.find(
|
||||
(x) => x.Language === settings?.defaultAudioLanguage
|
||||
)?.Index;
|
||||
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
|
||||
onChange(defaultAudioIndex);
|
||||
return;
|
||||
}
|
||||
const index = source.DefaultAudioStreamIndex;
|
||||
if (index !== undefined && index !== null) {
|
||||
console.log("DefaultAudioStreamIndex", index);
|
||||
onChange(index);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(0);
|
||||
}, [audioStreams, settings]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
|
||||
@@ -9,7 +9,7 @@ export type Bitrate = {
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const BITRATES: Bitrate[] = [
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
@@ -39,12 +39,12 @@ const BITRATES: Bitrate[] = [
|
||||
value: 250000,
|
||||
height: 480,
|
||||
},
|
||||
];
|
||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected: Bitrate;
|
||||
inverted?: boolean;
|
||||
selected?: Bitrate | null;
|
||||
inverted?: boolean | null;
|
||||
}
|
||||
|
||||
export const BitrateSelector: React.FC<Props> = ({
|
||||
@@ -77,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import React from "react";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
width?: number;
|
||||
useEpisodePoster?: boolean;
|
||||
size?: "small" | "normal";
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
width = 176,
|
||||
useEpisodePoster = false,
|
||||
size = "normal",
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
/**
|
||||
* Get horrizontal poster for movie and episode, with failover to primary.
|
||||
@@ -39,28 +40,38 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
else
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Program") {
|
||||
if (item.ImageTags?.["Thumb"])
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||
else
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
const startDate = new Date(item.StartDate || "");
|
||||
const endDate = new Date(item.EndDate || "");
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
} else {
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View
|
||||
className="aspect-video border border-neutral-800"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
></View>
|
||||
<View className="aspect-video border border-neutral-800 w-44"></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
|
||||
className={`
|
||||
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
|
||||
${size === "small" ? "w-32" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
key={item.Id}
|
||||
@@ -76,10 +87,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: `100%`,
|
||||
}}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||
></View>
|
||||
<View
|
||||
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 { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
@@ -17,20 +17,21 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} 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, useFocusEffect } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
import { toast } from "sonner-native";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
@@ -39,13 +40,14 @@ interface DownloadProps extends ViewProps {
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [process] = useAtom(runningProcesses);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { processes, startBackgroundDownload } = useDownload();
|
||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] =
|
||||
useState<MediaSourceInfo | null>(null);
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
@@ -54,6 +56,20 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
// 4. Set states
|
||||
setSelectedMediaSource(mediaSource);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [item, settings])
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(() => {
|
||||
return user?.Policy?.EnableContentDownloading;
|
||||
}, [user]);
|
||||
@@ -67,9 +83,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
console.log("handleSheetChanges", index);
|
||||
}, []);
|
||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
@@ -85,7 +99,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
);
|
||||
}
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
let deviceProfile: any = iosFmp4;
|
||||
|
||||
if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
@@ -115,6 +129,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
);
|
||||
|
||||
let url: string | undefined = undefined;
|
||||
let fileExtension: string | undefined | null = "mp4";
|
||||
|
||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
||||
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
||||
@@ -126,7 +141,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
|
||||
if (mediaSource.SupportsDirectPlay) {
|
||||
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}`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
@@ -149,40 +163,40 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
} else if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
fileExtension = mediaSource.TranscodingContainer;
|
||||
}
|
||||
|
||||
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,
|
||||
item,
|
||||
startRemuxing,
|
||||
startBackgroundDownload,
|
||||
user?.Id,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
maxBitrate,
|
||||
settings?.downloadMethod,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if item is downloaded
|
||||
*/
|
||||
const { data: downloaded, isFetching } = useQuery({
|
||||
queryKey: ["downloaded", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!item.Id) return false;
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
const data: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
);
|
||||
const isDownloaded = useMemo(() => {
|
||||
if (!downloadedFiles) return false;
|
||||
|
||||
return data.some((d) => d.Id === item.Id);
|
||||
},
|
||||
enabled: !!item.Id,
|
||||
});
|
||||
return downloadedFiles.some((file) => file.Id === item.Id);
|
||||
}, [downloadedFiles, item.Id]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
@@ -195,14 +209,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 (
|
||||
<View
|
||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader />
|
||||
) : process && process?.item.Id === item.Id ? (
|
||||
{process && process?.item.Id === item.Id ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
@@ -230,7 +248,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
>
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : downloaded ? (
|
||||
) : isDownloaded ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
@@ -290,25 +308,36 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
className="mt-auto"
|
||||
onPress={() => {
|
||||
if (userCanDownload === true) {
|
||||
if (!item.Id) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await initiateDownload();
|
||||
},
|
||||
item,
|
||||
});
|
||||
if (settings?.downloadMethod === "remux") {
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id,
|
||||
execute: async () => {
|
||||
await initiateDownload();
|
||||
},
|
||||
item,
|
||||
});
|
||||
} else {
|
||||
initiateDownload();
|
||||
}
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Disabled",
|
||||
"This user is not allowed to download files."
|
||||
);
|
||||
toast.error("You are not allowed to download files.");
|
||||
}
|
||||
}}
|
||||
color="purple"
|
||||
>
|
||||
Download
|
||||
</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>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
|
||||
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 }) => {
|
||||
return (
|
||||
<View className="mt-2 flex flex-col h-12">
|
||||
<View className="mt-2 flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} className="">
|
||||
|
||||
@@ -12,307 +12,205 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useFocusEffect, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
|
||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
|
||||
const [settings] = useSettings();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
const castDevice = useCastDevice();
|
||||
const navigation = useNavigation();
|
||||
const [settings] = useSettings();
|
||||
const [selectedMediaSource, setSelectedMediaSource] =
|
||||
useState<MediaSourceInfo | null>(null);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fadeIn = () => {
|
||||
opacity.value = withTiming(1, { duration: 300 });
|
||||
};
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const headerHeightRef = useRef(400);
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
const res = await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: id,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
enabled: !!id && !!api,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
});
|
||||
|
||||
const [localItem, setLocalItem] = useState(item);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
if (localItem) {
|
||||
// Fade out current item
|
||||
fadeOut(() => {
|
||||
// Update local item after fade out
|
||||
setLocalItem(item);
|
||||
// Then fade in
|
||||
fadeIn();
|
||||
setPlaySettings({
|
||||
item,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
} else {
|
||||
// If there's no current item, just set and fade in
|
||||
setLocalItem(item);
|
||||
fadeIn();
|
||||
}
|
||||
} else {
|
||||
// If item is null, fade out and clear local item
|
||||
fadeOut(() => setLocalItem(null));
|
||||
}
|
||||
}, [item]);
|
||||
}, [item, settings])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast background="blur" width={22} height={22} />
|
||||
<DownloadItem item={item} />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
const selectedMediaSource = useMemo(() => {
|
||||
return playSettings?.mediaSource || undefined;
|
||||
}, [playSettings?.mediaSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||
headerHeightRef.current = 230;
|
||||
return;
|
||||
}
|
||||
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
||||
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
||||
else headerHeightRef.current = 400;
|
||||
}, [item]);
|
||||
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
mediaSource,
|
||||
}));
|
||||
};
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
const selectedAudioStream = useMemo(() => {
|
||||
return playSettings?.audioIndex;
|
||||
}, [playSettings?.audioIndex]);
|
||||
|
||||
const setSelectedAudioStream = (audioIndex: number) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
audioIndex,
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedSubtitleStream = useMemo(() => {
|
||||
return playSettings?.subtitleIndex;
|
||||
}, [playSettings?.subtitleIndex]);
|
||||
|
||||
const setSelectedSubtitleStream = (subtitleIndex: number) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
subtitleIndex,
|
||||
}));
|
||||
};
|
||||
|
||||
const maxBitrate = useMemo(() => {
|
||||
return playSettings?.bitrate;
|
||||
}, [playSettings?.bitrate]);
|
||||
|
||||
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
|
||||
console.log("setMaxBitrate", bitrate);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
bitrate,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: [
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
|
||||
return null;
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
useImageColors({ item });
|
||||
|
||||
if (castDevice?.deviceId) {
|
||||
deviceProfile = chromecastProfile;
|
||||
} else if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
forceDirectPlay: settings?.forceDirectPlay,
|
||||
height: maxBitrate.height,
|
||||
mediaSourceId: selectedMediaSource.Id,
|
||||
});
|
||||
|
||||
console.info("Stream URL:", url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
const themeImageColorSource = useMemo(() => {
|
||||
if (!api || !item) return;
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
useImageColors(themeImageColorSource?.uri);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
|
||||
}, [isLoading, isFetching, loadingLogo, logoUrl]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeightRef.current}
|
||||
headerImage={
|
||||
<>
|
||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||
{localItem && (
|
||||
<ItemImage
|
||||
useThemeColor
|
||||
variant={
|
||||
localItem.Type === "Movie" && logoUrl
|
||||
? "Backdrop"
|
||||
: "Primary"
|
||||
}
|
||||
item={localItem}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast background="blur" width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadItem item={item} />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
// If landscape
|
||||
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||
setHeaderHeight(230);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col bg-transparent shrink">
|
||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||
<ItemHeader item={localItem} className="mb-4" />
|
||||
{localItem ? (
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
<ItemImage
|
||||
variant={
|
||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||
}
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col bg-transparent shrink">
|
||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||
<ItemHeader item={item} className="mb-4" />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||
<BitrateSelector
|
||||
className="mr-1"
|
||||
@@ -321,7 +219,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className="mr-1"
|
||||
item={localItem}
|
||||
item={item}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
@@ -341,33 +239,45 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View className="h-16">
|
||||
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
|
||||
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||
<PlayButton item={item} url={playUrl} className="grow" />
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
<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" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className="h-16"></View>
|
||||
</View>
|
||||
|
||||
{item?.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item?.Overview} className="px-4 mb-4" />
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
|
||||
{item?.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
<SimilarItems itemId={item?.Id} />
|
||||
|
||||
<View className="h-16"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { View, ViewProps } from "react-native";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -12,7 +14,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||
@@ -23,16 +25,22 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
minHeight: 96,
|
||||
}}
|
||||
className="flex flex-col"
|
||||
{...props}
|
||||
>
|
||||
<Ratings item={item} className="mb-2" />
|
||||
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
|
||||
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Ratings item={item} className="mb-2" />
|
||||
{item.Type === "Episode" && (
|
||||
<>
|
||||
<EpisodeTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</>
|
||||
)}
|
||||
{item.Type === "Movie" && (
|
||||
<>
|
||||
<MoviesTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,11 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-bold ">{title}</Text>
|
||||
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
||||
{subTitle && (
|
||||
<Text className="text-xs" selectable>
|
||||
{subTitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{iconAfter}
|
||||
</View>
|
||||
|
||||
@@ -7,11 +7,12 @@ import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
onChange: (value: MediaSourceInfo) => void;
|
||||
selected: MediaSourceInfo | null;
|
||||
selected?: MediaSourceInfo | null;
|
||||
}
|
||||
|
||||
export const MediaSourceSelector: React.FC<Props> = ({
|
||||
@@ -20,21 +21,19 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const mediaSources = useMemo(() => {
|
||||
return item.MediaSources;
|
||||
}, [item]);
|
||||
|
||||
const selectedMediaSource = useMemo(
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
mediaSources
|
||||
?.find((x) => x.Id === selected?.Id)
|
||||
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
|
||||
[mediaSources, selected]
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
(x) => x.Type === "Video"
|
||||
)?.DisplayTitle || "",
|
||||
[item.MediaSources, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaSources?.length) onChange(mediaSources[0]);
|
||||
}, [mediaSources]);
|
||||
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
|
||||
onChange(item.MediaSources[0]);
|
||||
}
|
||||
}, [item.MediaSources, selected]);
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
if (name && name.length > 40)
|
||||
@@ -55,8 +54,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
|
||||
<Text numberOfLines={1}>{selectedMediaSource}</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -70,7 +69,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||
{mediaSources?.map((source, idx: number) => (
|
||||
{item.MediaSources?.map((source, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
@@ -78,7 +77,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{name(source.Name)}
|
||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||
source.Size
|
||||
)}`}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
|
||||
const onError = (error: any) => {
|
||||
console.error("Video Error: ", error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.resume();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Video
|
||||
source={{
|
||||
uri: url,
|
||||
isNetwork: false,
|
||||
}}
|
||||
ref={videoRef}
|
||||
onError={onError}
|
||||
ignoreSilentSwitch="ignore"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,7 @@ export const OverviewText: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<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
|
||||
onPress={() =>
|
||||
setLimit((prev) =>
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,14 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Linking, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
@@ -27,6 +26,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -39,12 +40,13 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||
|
||||
@@ -54,17 +56,33 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const startColor = useSharedValue(memoizedColor);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const directStream = useMemo(() => {
|
||||
return !url?.includes("m3u8");
|
||||
}, []);
|
||||
}, [url]);
|
||||
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
if (!client) {
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
if (!url || !item) {
|
||||
console.warn(
|
||||
"No URL or item provided to PlayButton",
|
||||
url?.slice(0, 100),
|
||||
item?.Id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
const vlcLink = "vlc://" + url;
|
||||
if (vlcLink && settings?.openInVLC) {
|
||||
Linking.openURL(vlcLink);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/play-video");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
@@ -159,7 +177,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
router.push("/play-video");
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
@@ -226,7 +244,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.average, endColor.value.average]
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -279,7 +297,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle]}
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
/>
|
||||
<View
|
||||
@@ -302,6 +320,15 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
<Feather name="cast" size={22} />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -21,11 +21,17 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
|
||||
const invalidateQueries = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item"],
|
||||
queryKey: ["item", item.Id],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["continueWatching"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp"],
|
||||
});
|
||||
@@ -38,6 +44,9 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { ItemCardText } from "./ItemCardText";
|
||||
import { Loader } from "./Loader";
|
||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||
|
||||
interface SimilarItemsProps extends ViewProps {
|
||||
itemId?: string | null;
|
||||
@@ -46,29 +48,24 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
||||
{isLoading ? (
|
||||
<View className="my-12">
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView horizontal>
|
||||
<View className="px-4 flex flex-row gap-x-2">
|
||||
{movies.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
{movies.length === 0 && (
|
||||
<Text className="px-4 text-neutral-500">No similar items</Text>
|
||||
)}
|
||||
<HorizontalScroll
|
||||
data={movies}
|
||||
loading={isLoading}
|
||||
height={247}
|
||||
noItemsText="No similar items found"
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
selected?: number | null;
|
||||
}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
@@ -23,8 +17,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||
[source]
|
||||
@@ -35,23 +27,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
[subtitleStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// const index = source.DefaultAudioStreamIndex;
|
||||
// if (index !== undefined && index !== null) {
|
||||
// onChange(index);
|
||||
// return;
|
||||
// }
|
||||
const defaultSubIndex = subtitleStreams?.find(
|
||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
||||
)?.Index;
|
||||
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
|
||||
onChange(defaultSubIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(-1);
|
||||
}, [subtitleStreams, settings]);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
@@ -21,7 +22,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (background === "transparent")
|
||||
if (background === "transparent" && Platform.OS !== "android")
|
||||
return (
|
||||
<BlurView
|
||||
{...props}
|
||||
@@ -52,7 +53,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color="#077DF2"
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ interface HorizontalScrollProps<T>
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
extraData?: any;
|
||||
noItemsText?: string;
|
||||
}
|
||||
|
||||
export const HorizontalScroll = forwardRef<
|
||||
@@ -38,6 +39,7 @@ export const HorizontalScroll = forwardRef<
|
||||
loading = false,
|
||||
height = 164,
|
||||
extraData,
|
||||
noItemsText,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>,
|
||||
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||
@@ -91,10 +93,11 @@ export const HorizontalScroll = forwardRef<
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
<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>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ interface Props extends ImageProps {
|
||||
| "Thumb";
|
||||
quality?: number;
|
||||
width?: number;
|
||||
useThemeColor?: boolean;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
@@ -31,7 +30,6 @@ export const ItemImage: React.FC<Props> = ({
|
||||
variant = "Primary",
|
||||
quality = 90,
|
||||
width = 1000,
|
||||
useThemeColor = false,
|
||||
onError,
|
||||
...props
|
||||
}) => {
|
||||
|
||||
@@ -8,6 +8,46 @@ interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
if (item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
|
||||
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>> = ({
|
||||
item,
|
||||
children,
|
||||
@@ -23,54 +63,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
if (item.Type === "Series") {
|
||||
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
|
||||
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}`);
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
|
||||
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 * as ContextMenu from "zeego/context-menu";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
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 { useFiles } from "@/hooks/useFiles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Image } from "expo-image";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface EpisodeCardProps {
|
||||
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.
|
||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
||||
*/
|
||||
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 () => {
|
||||
startDownloadedFilePlayback({
|
||||
item,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item, startDownloadedFilePlayback]);
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
@@ -42,43 +47,70 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
const contextMenuOptions = [
|
||||
{
|
||||
label: "Delete",
|
||||
onSelect: handleDeleteFile,
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
const showActionSheet = useCallback(() => {
|
||||
const options = ["Delete", "Cancel"];
|
||||
const destructiveButtonIndex = 0;
|
||||
const cancelButtonIndex = 1;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
switch (selectedIndex) {
|
||||
case destructiveButtonIndex:
|
||||
// Delete
|
||||
handleDeleteFile();
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
// Cancelled
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
alignOffset={0}
|
||||
avoidCollisions
|
||||
collisionPadding={10}
|
||||
loop={false}
|
||||
>
|
||||
{contextMenuOptions.map((option) => (
|
||||
<ContextMenu.Item
|
||||
key={option.label}
|
||||
onSelect={option.onSelect}
|
||||
destructive={option.destructive}
|
||||
>
|
||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
||||
{option.label}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
{base64Image ? (
|
||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="gray"
|
||||
className="self-center mt-16"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<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 * as ContextMenu from "zeego/context-menu";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
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 { Text } from "../common/Text";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
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 {
|
||||
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.
|
||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { startDownloadedFilePlayback } = usePlayback();
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
startDownloadedFilePlayback({
|
||||
item,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item, startDownloadedFilePlayback]);
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
@@ -45,48 +49,64 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
const contextMenuOptions = [
|
||||
{
|
||||
label: "Delete",
|
||||
onSelect: handleDeleteFile,
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
const showActionSheet = useCallback(() => {
|
||||
const options = ["Delete", "Cancel"];
|
||||
const destructiveButtonIndex = 0;
|
||||
const cancelButtonIndex = 1;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
switch (selectedIndex) {
|
||||
case destructiveButtonIndex:
|
||||
// Delete
|
||||
handleDeleteFile();
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
// Cancelled
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
loop={false}
|
||||
alignOffset={0}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
>
|
||||
{contextMenuOptions.map((option) => (
|
||||
<ContextMenu.Item
|
||||
key={option.label}
|
||||
onSelect={option.onSelect}
|
||||
destructive={option.destructive}
|
||||
>
|
||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
||||
{option.label}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
||||
{base64Image ? (
|
||||
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="gray"
|
||||
className="self-center mt-16"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 { View } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { EpisodeCard } from "./EpisodeCard";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMemo } from "react";
|
||||
@@ -22,26 +22,32 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
||||
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
|
||||
<View className="flex flex-row items-center justify-between px-4">
|
||||
<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">
|
||||
<Text className="text-xs font-bold">{items.length}</Text>
|
||||
</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) => (
|
||||
<View key={seasonIndex}>
|
||||
<Text className="mb-2 font-semibold">
|
||||
<Text className="mb-2 font-semibold px-4">
|
||||
{seasonItems[0].SeasonName}
|
||||
</Text>
|
||||
{seasonItems.map((item, index) => (
|
||||
<View className="mb-2" key={index}>
|
||||
<EpisodeCard item={item} />
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
||||
<EpisodeCard item={item} key={index} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -4,25 +4,29 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
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 { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo } from "react";
|
||||
import { Dimensions, View, ViewProps } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Carousel, {
|
||||
ICarouselInstance,
|
||||
Pagination,
|
||||
} from "react-native-reanimated-carousel";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
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 {}
|
||||
|
||||
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const ref = React.useRef<ICarouselInstance>(null);
|
||||
@@ -80,13 +84,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
const width = Dimensions.get("screen").width;
|
||||
|
||||
if (l1 || l2)
|
||||
return (
|
||||
<View className="h-[242px] flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1 || l2) return null;
|
||||
if (!popularItems) return null;
|
||||
|
||||
return (
|
||||
@@ -122,7 +120,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
|
||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const router = useRouter();
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
|
||||
const uri = useMemo(() => {
|
||||
@@ -141,11 +139,41 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
return getLogoImageUrlById({ api, item, height: 100 });
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<TouchableItemRouter item={item}>
|
||||
<View className="px-4">
|
||||
<GestureDetector gesture={tap}>
|
||||
<Animated.View
|
||||
style={{
|
||||
opacity: opacity,
|
||||
}}
|
||||
className="px-4"
|
||||
>
|
||||
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
||||
<Image
|
||||
source={{
|
||||
@@ -171,7 +199,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
useQuery,
|
||||
type QueryFunction,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { ScrollView, View, ViewProps } from "react-native";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
height?: "small" | "large";
|
||||
disabled?: boolean;
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
@@ -26,58 +23,93 @@ interface Props extends ViewProps {
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
title,
|
||||
orientation = "vertical",
|
||||
height = "small",
|
||||
disabled = false,
|
||||
queryFn,
|
||||
queryKey,
|
||||
...props
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !disabled,
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
if (disabled || !title) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
<View {...props} className="">
|
||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||
{title}
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
height={orientation === "vertical" ? 247 : 164}
|
||||
loading={isLoading}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableItemRouter
|
||||
key={index}
|
||||
item={item}
|
||||
className={`flex flex-col
|
||||
{isLoading === false && data?.length === 0 && (
|
||||
<View className="px-4">
|
||||
<Text className="text-neutral-500">No items</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<View
|
||||
className={`
|
||||
flex flex-row gap-2 px-4
|
||||
`}
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className="w-44" key={i}>
|
||||
<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"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
{item.Type === "Program" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,17 +15,13 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacityProps, View } from "react-native";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
library: BaseItemDto;
|
||||
}
|
||||
|
||||
type LibraryColor = {
|
||||
dominantColor: string;
|
||||
averageColor: string;
|
||||
secondary: string;
|
||||
};
|
||||
|
||||
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||
|
||||
const icons: Record<CollectionType, IconName> = {
|
||||
@@ -48,12 +44,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const [imageInfo, setImageInfo] = useState<LibraryColor>({
|
||||
dominantColor: "#fff",
|
||||
averageColor: "#fff",
|
||||
secondary: "#fff",
|
||||
});
|
||||
|
||||
const url = useMemo(
|
||||
() =>
|
||||
getPrimaryImageUrl({
|
||||
@@ -63,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
[library]
|
||||
);
|
||||
|
||||
// If we want to use image colors for library cards
|
||||
// const [color] = useAtom(itemThemeColorAtom)
|
||||
// useImageColors({ url });
|
||||
|
||||
const { data: itemsCount } = useQuery({
|
||||
queryKey: ["library-count", library.Id],
|
||||
queryFn: async () => {
|
||||
@@ -76,40 +70,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
getColors(url, {
|
||||
fallback: "#fff",
|
||||
cache: true,
|
||||
key: url,
|
||||
})
|
||||
.then((colors) => {
|
||||
let dominantColor: string = "#fff";
|
||||
let averageColor: string = "#fff";
|
||||
let secondary: string = "#fff";
|
||||
|
||||
if (colors.platform === "android") {
|
||||
dominantColor = colors.dominant;
|
||||
averageColor = colors.average;
|
||||
secondary = colors.muted;
|
||||
} else if (colors.platform === "ios") {
|
||||
dominantColor = colors.primary;
|
||||
averageColor = colors.background;
|
||||
secondary = colors.detail;
|
||||
}
|
||||
|
||||
setImageInfo({
|
||||
dominantColor,
|
||||
averageColor,
|
||||
secondary,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
if (settings?.libraryOptions?.display === "row") {
|
||||
@@ -125,7 +85,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
{library.Name}
|
||||
</Text>
|
||||
{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
|
||||
</Text>
|
||||
)}
|
||||
|
||||
43
components/livetv/HourHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
export const HourHeader = ({ height }: { height: number }) => {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const hoursRemaining = 24 - currentHour;
|
||||
const hours = generateHours(currentHour, hoursRemaining);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex flex-row"
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
>
|
||||
{hours.map((hour, index) => (
|
||||
<HourCell key={index} hour={hour} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const HourCell = ({ hour }: { hour: Date }) => (
|
||||
<View className="w-[200px] flex items-center justify-center bg-neutral-800">
|
||||
<Text className="text-xs text-gray-600">
|
||||
{hour.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const generateHours = (startHour: number, count: number): Date[] => {
|
||||
const now = new Date();
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const hour = new Date(now);
|
||||
hour.setHours(startHour + i, 0, 0, 0);
|
||||
return hour;
|
||||
});
|
||||
};
|
||||
96
components/livetv/LiveTVGuideRow.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Dimensions, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
export const LiveTVGuideRow = ({
|
||||
channel,
|
||||
programs,
|
||||
scrollX = 0,
|
||||
isVisible = true,
|
||||
}: {
|
||||
channel: BaseItemDto;
|
||||
programs?: BaseItemDto[] | null;
|
||||
scrollX?: number;
|
||||
isVisible?: boolean;
|
||||
}) => {
|
||||
const positionRefs = useRef<{ [key: string]: number }>({});
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const calculateWidth = (s?: string | null, e?: string | null) => {
|
||||
if (!s || !e) return 0;
|
||||
const start = new Date(s);
|
||||
const end = new Date(e);
|
||||
const duration = end.getTime() - start.getTime();
|
||||
const minutes = duration / 60000;
|
||||
const width = (minutes / 60) * 200;
|
||||
return width;
|
||||
};
|
||||
|
||||
const programsWithPositions = useMemo(() => {
|
||||
let cumulativeWidth = 0;
|
||||
return programs
|
||||
?.filter((p) => p.ChannelId === channel.Id)
|
||||
.map((p) => {
|
||||
const width = calculateWidth(p.StartDate, p.EndDate);
|
||||
const position = cumulativeWidth;
|
||||
cumulativeWidth += width;
|
||||
return { ...p, width, position };
|
||||
});
|
||||
}, [programs, channel.Id]);
|
||||
|
||||
const isCurrentlyLive = (program: BaseItemDto) => {
|
||||
if (!program.StartDate || !program.EndDate) return false;
|
||||
const now = new Date();
|
||||
const start = new Date(program.StartDate);
|
||||
const end = new Date(program.EndDate);
|
||||
return now >= start && now <= end;
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return <View style={{ height: 64 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={channel.ChannelNumber} className="flex flex-row h-16">
|
||||
{programsWithPositions?.map((p) => (
|
||||
<TouchableItemRouter item={p} key={p.Id}>
|
||||
<View
|
||||
style={{
|
||||
width: p.width,
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
left: p.position,
|
||||
backgroundColor: isCurrentlyLive(p)
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "transparent",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 overflow-hidden"
|
||||
>
|
||||
{(() => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginLeft:
|
||||
p.width > screenWidth && scrollX > p.position
|
||||
? scrollX - p.position
|
||||
: 0,
|
||||
}}
|
||||
className="px-4 self-start"
|
||||
>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="text-xs text-start self-start"
|
||||
>
|
||||
{p.Name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -31,10 +31,10 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: collection, isLoading } = useQuery({
|
||||
const { data: collection } = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
|
||||
@@ -9,7 +9,9 @@ interface Props extends ViewProps {
|
||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
return (
|
||||
<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>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
@@ -35,11 +33,11 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const router = useRouter();
|
||||
const client = useRemoteMediaClient();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
const { setPlaySettings } = usePlaySettings();
|
||||
|
||||
const openSelect = () => {
|
||||
if (!castDevice?.deviceId) {
|
||||
@@ -70,32 +68,18 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const play = async (type: "device" | "cast") => {
|
||||
const play = useCallback(async (type: "device" | "cast") => {
|
||||
if (!user?.Id || !api || !item.Id) {
|
||||
console.warn("No user, api or item", user, api, item.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
const sessionData = response.data;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
const data = await setPlaySettings({
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||
mediaSourceId: item.Id,
|
||||
});
|
||||
|
||||
if (!url || !item) {
|
||||
console.warn("No url or item", url, item.Id);
|
||||
return;
|
||||
if (!data?.url) {
|
||||
throw new Error("play-music ~ No stream url");
|
||||
}
|
||||
|
||||
if (type === "cast" && client) {
|
||||
@@ -105,7 +89,7 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: url,
|
||||
contentUrl: data.url!,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
@@ -118,13 +102,10 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("Playing on device", url, item.Id);
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
url,
|
||||
});
|
||||
console.log("Playing on device", data.url, item.Id);
|
||||
router.push("/play-music");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -36,7 +36,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
}, [item]);
|
||||
|
||||
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
|
||||
placeholder={{
|
||||
blurhash,
|
||||
@@ -57,7 +57,6 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className="h-1 bg-red-600 w-full"></View>
|
||||
|
||||
@@ -32,7 +32,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
}, [item]);
|
||||
|
||||
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
|
||||
placeholder={{
|
||||
blurhash,
|
||||
@@ -49,7 +49,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
height: "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>
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
height={247}
|
||||
data={item?.People || []}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
@@ -32,7 +33,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
router.push(`/actors/${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
||||
<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>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
height={247}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
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
|
||||
item={item}
|
||||
|
||||
@@ -12,7 +12,9 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props extends React.ComponentProps<typeof Button> {
|
||||
type?: "next" | "previous";
|
||||
}
|
||||
|
||||
export const NextEpisodeButton: React.FC<Props> = ({
|
||||
export const NextItemButton: React.FC<Props> = ({
|
||||
item,
|
||||
type = "next",
|
||||
...props
|
||||
@@ -23,8 +23,8 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { data: nextEpisode } = useQuery({
|
||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||
const { data: nextItem } = useQuery({
|
||||
queryKey: ["nextItem", item.Id, item.ParentId, type],
|
||||
queryFn: async () => {
|
||||
if (
|
||||
!api ||
|
||||
@@ -47,16 +47,16 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
if (!nextEpisode) return true;
|
||||
if (nextEpisode.Id === item.Id) return true;
|
||||
if (!nextItem) return true;
|
||||
if (nextItem.Id === item.Id) return true;
|
||||
return false;
|
||||
}, [nextEpisode, type]);
|
||||
}, [nextItem, type]);
|
||||
|
||||
if (item.Type !== "Episode") return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => router.setParams({ id: nextEpisode?.Id })}
|
||||
onPress={() => router.setParams({ id: nextItem?.Id })}
|
||||
className={`h-12 aspect-square`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
@@ -10,6 +10,7 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -46,16 +47,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/page?id=${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={index}
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} useEpisodePoster />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
userId: user?.Id,
|
||||
itemId: previousId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
userId: user?.Id,
|
||||
itemId: nextId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
});
|
||||
}
|
||||
}, [episodes, api, user?.Id, item]);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Image } from "expo-image";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -192,18 +193,16 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
</View>
|
||||
) : (
|
||||
episodes?.map((e: BaseItemDto) => (
|
||||
<TouchableOpacity
|
||||
<TouchableItemRouter
|
||||
item={e}
|
||||
key={e.Id}
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/page?id=${e.Id}`);
|
||||
}}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
<View className="flex flex-row items-center mb-2">
|
||||
<View className="w-32 aspect-video overflow-hidden mr-2">
|
||||
<View className="flex flex-row items-start mb-2">
|
||||
<View className="mr-2">
|
||||
<ContinueWatchingPoster
|
||||
size="small"
|
||||
item={e}
|
||||
width={128}
|
||||
useEpisodePoster
|
||||
/>
|
||||
</View>
|
||||
@@ -218,7 +217,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto">
|
||||
<View className="self-start ml-auto -mt-0.5">
|
||||
<DownloadItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -229,7 +228,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
>
|
||||
{e.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
DefaultLanguageOption,
|
||||
DownloadOptions,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { Input } from "../common/Input";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../Button";
|
||||
|
||||
const LANGUAGES: DefaultLanguageOption[] = [
|
||||
{ label: "eng", value: "eng" },
|
||||
{
|
||||
label: "sv",
|
||||
value: "sv",
|
||||
},
|
||||
];
|
||||
import { LANGUAGES } from "@/constants/Languages";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||
@@ -144,6 +121,82 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">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>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,85 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
DefaultLanguageOption,
|
||||
DownloadOptions,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
userAtom,
|
||||
} 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 { 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 { 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 { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { Input } from "../common/Input";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../Button";
|
||||
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 { setProcesses } = useDownload();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
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 {
|
||||
data: mediaListCollections,
|
||||
isLoading: isLoadingMediaListCollections,
|
||||
@@ -48,8 +102,10 @@ export const SettingToggles: React.FC = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View {...props}>
|
||||
{/* <View>
|
||||
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
||||
@@ -70,7 +126,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View className="flex flex-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="shrink">
|
||||
<Text className="font-semibold">Auto rotate</Text>
|
||||
@@ -80,25 +136,116 @@ export const SettingToggles: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.autoRotate}
|
||||
value={settings.autoRotate}
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||
<View
|
||||
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
||||
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">
|
||||
Clicking a video will start it in fullscreen mode, instead of
|
||||
inline.
|
||||
Set the full screen video player orientation.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openFullScreenVideoPlayerByDefault}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ openFullScreenVideoPlayerByDefault: value })
|
||||
}
|
||||
/>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.DEFAULT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.DEFAULT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="4"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
@@ -110,7 +257,7 @@ export const SettingToggles: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
value={settings.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
@@ -133,13 +280,13 @@ export const SettingToggles: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.usePopularPlugin}
|
||||
value={settings.usePopularPlugin}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ usePopularPlugin: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{settings?.usePopularPlugin && (
|
||||
{settings.usePopularPlugin && (
|
||||
<View className="flex flex-col py-2 bg-neutral-900">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<View
|
||||
@@ -150,9 +297,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<Text className="font-semibold">{mlc.Name}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.mediaListCollectionIds?.includes(
|
||||
mlc.Id!
|
||||
)}
|
||||
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
@@ -163,11 +308,11 @@ export const SettingToggles: React.FC = () => {
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings?.mediaListCollectionIds.filter(
|
||||
settings.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
||||
: [...settings.mediaListCollectionIds, mlc.Id!],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -198,7 +343,7 @@ export const SettingToggles: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.forceDirectPlay}
|
||||
value={settings.forceDirectPlay}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ forceDirectPlay: value })
|
||||
}
|
||||
@@ -207,9 +352,9 @@ export const SettingToggles: React.FC = () => {
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||
`}
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Device profile</Text>
|
||||
@@ -221,7 +366,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.deviceProfile}</Text>
|
||||
<Text>{settings.deviceProfile}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
@@ -261,11 +406,12 @@ export const SettingToggles: React.FC = () => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Search engine</Text>
|
||||
@@ -276,7 +422,7 @@ export const SettingToggles: React.FC = () => {
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.searchEngine}</Text>
|
||||
<Text>{settings.searchEngine}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
@@ -310,42 +456,179 @@ export const SettingToggles: React.FC = () => {
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
{settings?.searchEngine === "Marlin" && (
|
||||
{settings.searchEngine === "Marlin" && (
|
||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||
<>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className="grow">
|
||||
<Input
|
||||
placeholder="Marlin Server URL..."
|
||||
defaultValue={settings.marlinServerUrl}
|
||||
value={marlinUrl}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={(text) => setMarlinUrl(text)}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
color="purple"
|
||||
className="shrink w-16 h-12"
|
||||
onPress={() => {
|
||||
updateSettings({ marlinServerUrl: marlinUrl });
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className="grow">
|
||||
<Input
|
||||
placeholder="Marlin Server URL..."
|
||||
defaultValue={settings.marlinServerUrl}
|
||||
value={marlinUrl}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={(text) => setMarlinUrl(text)}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
color="purple"
|
||||
className="shrink w-16 h-12"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
marlinServerUrl: marlinUrl.endsWith("/")
|
||||
? marlinUrl
|
||||
: marlinUrl + "/",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{settings.marlinServerUrl && (
|
||||
<Text className="text-neutral-500 mt-2">
|
||||
{settings?.marlinServerUrl}
|
||||
Current: {settings.marlinServerUrl}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Chromecast } from "../Chromecast";
|
||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||
|
||||
const commonScreenOptions = {
|
||||
@@ -15,7 +13,6 @@ const routes = [
|
||||
"albums/[albumId]",
|
||||
"artists/index",
|
||||
"artists/[artistId]",
|
||||
"collections/[collectionId]",
|
||||
"items/page",
|
||||
"series/[id]",
|
||||
];
|
||||
|
||||
529
components/video-player/Controls.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { VideoRef } from "react-native-video";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
isBuffering: boolean;
|
||||
showControls: boolean;
|
||||
ignoreSafeAreas?: boolean;
|
||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
}
|
||||
|
||||
export const Controls: React.FC<Props> = ({
|
||||
item,
|
||||
videoRef,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
progress,
|
||||
isBuffering,
|
||||
cacheProgress,
|
||||
showControls,
|
||||
setShowControls,
|
||||
ignoreSafeAreas,
|
||||
setIgnoreSafeAreas,
|
||||
enableTrickplay = true,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setPlaySettings } = usePlaySettings();
|
||||
|
||||
const windowDimensions = Dimensions.get("window");
|
||||
|
||||
const op = useSharedValue<number>(1);
|
||||
const tr = useSharedValue<number>(10);
|
||||
const animatedStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
};
|
||||
});
|
||||
const animatedTopStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
transform: [
|
||||
{
|
||||
translateY: -tr.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const animatedBottomStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
transform: [
|
||||
{
|
||||
translateY: tr.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (showControls || isBuffering) {
|
||||
op.value = withTiming(1, { duration: 200 });
|
||||
tr.value = withTiming(0, { duration: 200 });
|
||||
} else {
|
||||
op.value = withTiming(0, { duration: 200 });
|
||||
tr.value = withTiming(10, { duration: 200 });
|
||||
}
|
||||
}, [showControls, isBuffering]);
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
enableTrickplay
|
||||
);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0); // Seconds
|
||||
const [remainingTime, setRemainingTime] = useState(0); // Seconds
|
||||
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = ticksToSeconds(currentProgress);
|
||||
const remaining = ticksToSeconds(maxValue - currentProgress);
|
||||
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id,
|
||||
currentTime,
|
||||
videoRef
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
item.Id,
|
||||
currentTime,
|
||||
videoRef
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(previousItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: previousItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
router.replace("/play-video");
|
||||
}, [previousItem, settings]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(nextItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: nextItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
router.replace("/play-video");
|
||||
}, [nextItem, settings]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
progress: progress.value,
|
||||
max: max.value,
|
||||
isSeeking: isSeeking.value,
|
||||
}),
|
||||
(result) => {
|
||||
if (result.isSeeking === false) {
|
||||
runOnJS(updateTimes)(result.progress, result.max);
|
||||
}
|
||||
},
|
||||
[updateTimes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = item.RunTimeTicks || 0;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const toggleControls = () => setShowControls(!showControls);
|
||||
|
||||
const handleSliderComplete = useCallback((value: number) => {
|
||||
progress.value = value;
|
||||
isSeeking.value = false;
|
||||
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, []);
|
||||
|
||||
const handleSliderChange = (value: number) => {
|
||||
calculateTrickplayUrl(value);
|
||||
};
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (showControls === false) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
videoRef.current?.pause();
|
||||
isSeeking.value = true;
|
||||
}, [showControls, isPlaying]);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
console.log("handleSkipBackward");
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
||||
setTimeout(() => {
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
console.log("handleSkipForward");
|
||||
if (!settings?.forwardSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
||||
setTimeout(() => {
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||
setIgnoreSafeAreas((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: windowDimensions.width,
|
||||
height: windowDimensions.height,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 97,
|
||||
right: insets.right,
|
||||
},
|
||||
]}
|
||||
className={`z-10 p-4
|
||||
${showSkipButton ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<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>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 94,
|
||||
right: insets.right,
|
||||
height: 70,
|
||||
}}
|
||||
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
||||
className={`z-10 p-4 ${
|
||||
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
toggleControls();
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: windowDimensions.width + 100,
|
||||
height: windowDimensions.height + 100,
|
||||
},
|
||||
animatedStyles,
|
||||
]}
|
||||
className={`bg-black/50 z-0`}
|
||||
></Animated.View>
|
||||
</Pressable>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: windowDimensions.width,
|
||||
height: windowDimensions.height,
|
||||
}}
|
||||
pointerEvents="none"
|
||||
className={`flex flex-col items-center justify-center
|
||||
${isBuffering ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
right: insets.right,
|
||||
},
|
||||
animatedTopStyles,
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={toggleIgnoreSafeAreas}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons
|
||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
width: windowDimensions.width - insets.left - insets.right,
|
||||
maxHeight: windowDimensions.height,
|
||||
left: insets.left,
|
||||
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
||||
},
|
||||
animatedBottomStyles,
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-col p-4 `}
|
||||
>
|
||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||
<Text className="font-bold">{item?.Name}</Text>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4">
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !previousItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToPreviousItem}
|
||||
>
|
||||
<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={() => {
|
||||
togglePlay(progress.value);
|
||||
}}
|
||||
>
|
||||
<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={goToNextItem}
|
||||
>
|
||||
<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>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
const tintColorLight = "#0a7ea4";
|
||||
const tintColorDark = "#fff";
|
||||
|
||||
export const Colors = {
|
||||
primary: "#9334E9",
|
||||
text: "#ECEDEE",
|
||||
background: "#151718",
|
||||
tint: tintColorDark,
|
||||
tint: "#fff",
|
||||
icon: "#9BA1A6",
|
||||
tabIconDefault: "#9BA1A6",
|
||||
tabIconSelected: "#9333ea",
|
||||
|
||||
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: "spa" },
|
||||
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||
{ label: "Hindi", value: "hin" },
|
||||
{ label: "Arabic", value: "ara" },
|
||||
{ label: "French", value: "fra" },
|
||||
{ label: "Russian", value: "rus" },
|
||||
{ label: "Portuguese", value: "por" },
|
||||
{ label: "Japanese", value: "jpn" },
|
||||
{ label: "German", value: "deu" },
|
||||
{ label: "Italian", value: "ita" },
|
||||
{ label: "Korean", value: "kor" },
|
||||
{ label: "Turkish", value: "tur" },
|
||||
{ label: "Dutch", value: "nld" },
|
||||
{ label: "Polish", value: "pol" },
|
||||
{ label: "Vietnamese", value: "vie" },
|
||||
{ label: "Thai", value: "tha" },
|
||||
{ label: "Indonesian", value: "ind" },
|
||||
{ label: "Greek", value: "ell" },
|
||||
{ label: "Swedish", value: "swe" },
|
||||
{ label: "Danish", value: "dan" },
|
||||
{ label: "Norwegian", value: "nor" },
|
||||
{ label: "Finnish", value: "fin" },
|
||||
{ label: "Czech", value: "ces" },
|
||||
{ label: "Hungarian", value: "hun" },
|
||||
{ label: "Romanian", value: "ron" },
|
||||
{ label: "Ukrainian", value: "ukr" },
|
||||
{ label: "Hebrew", value: "heb" },
|
||||
{ label: "Bengali", value: "ben" },
|
||||
{ label: "Punjabi", value: "pan" },
|
||||
{ label: "Tagalog", value: "tgl" },
|
||||
{ label: "Swahili", value: "swa" },
|
||||
{ label: "Malay", value: "msa" },
|
||||
{ label: "Persian", value: "fas" },
|
||||
{ label: "Urdu", value: "urd" },
|
||||
];
|
||||
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
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 9.1.0"
|
||||
"version": ">= 9.1.0",
|
||||
"appVersionSource": "local"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -21,13 +22,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.12.0",
|
||||
"channel": "0.17.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.12.0",
|
||||
"channel": "0.17.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
99
hooks/useAdjacentEpisodes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import index from "@/app/(auth)/(tabs)/(home)";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
interface AdjacentEpisodesProps {
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const { data: previousItem } = useQuery({
|
||||
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
const parentId = item?.AlbumId || item?.ParentId;
|
||||
const indexNumber = item?.IndexNumber;
|
||||
|
||||
console.log("Getting previous item for " + indexNumber);
|
||||
if (
|
||||
!api ||
|
||||
!parentId ||
|
||||
indexNumber === undefined ||
|
||||
indexNumber === null ||
|
||||
indexNumber - 1 < 1
|
||||
) {
|
||||
console.log("No previous item", {
|
||||
itemIndex: indexNumber,
|
||||
itemId: item?.Id,
|
||||
parentId: parentId,
|
||||
indexNumber: indexNumber,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const newIndexNumber = indexNumber - 2;
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: parentId!,
|
||||
startIndex: newIndexNumber,
|
||||
limit: 1,
|
||||
sortBy: ["IndexNumber"],
|
||||
includeItemTypes: ["Episode", "Audio"],
|
||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||
});
|
||||
|
||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
|
||||
throw new Error("Previous item is not correct");
|
||||
}
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: nextItem } = useQuery({
|
||||
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
||||
const parentId = item?.AlbumId || item?.ParentId;
|
||||
const indexNumber = item?.IndexNumber;
|
||||
|
||||
if (
|
||||
!api ||
|
||||
!parentId ||
|
||||
indexNumber === undefined ||
|
||||
indexNumber === null
|
||||
) {
|
||||
console.log("No next item", {
|
||||
itemId: item?.Id,
|
||||
parentId: parentId,
|
||||
indexNumber: indexNumber,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getItemsApi(api).getItems({
|
||||
parentId: parentId!,
|
||||
startIndex: indexNumber,
|
||||
sortBy: ["IndexNumber"],
|
||||
limit: 1,
|
||||
includeItemTypes: ["Episode", "Audio"],
|
||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||
});
|
||||
|
||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
|
||||
throw new Error("Previous item is not correct");
|
||||
}
|
||||
|
||||
return res.data.Items?.[0] || null;
|
||||
},
|
||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return { previousItem, nextItem };
|
||||
};
|
||||
17
hooks/useAndroidNavigationBar.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export const useAndroidNavigationBar = () => {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setVisibilityAsync("hidden");
|
||||
NavigationBar.setBehaviorAsync("overlay-swipe");
|
||||
|
||||
return () => {
|
||||
NavigationBar.setVisibilityAsync("visible");
|
||||
NavigationBar.setBehaviorAsync("inset-swipe");
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
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 };
|
||||
};
|
||||
77
hooks/useCreditSkipper.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (creditTimestamps) {
|
||||
setShowSkipCreditButton(
|
||||
currentTime > creditTimestamps.Credits.Start &&
|
||||
currentTime < creditTimestamps.Credits.End
|
||||
);
|
||||
}
|
||||
}, [creditTimestamps, currentTime]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
console.log("skipCredits");
|
||||
if (!creditTimestamps || !videoRef.current) return;
|
||||
try {
|
||||
videoRef.current.seek(creditTimestamps.Credits.End);
|
||||
setTimeout(() => {
|
||||
videoRef.current?.resume();
|
||||
}, 200);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
52
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// hooks/useFileOpener.ts
|
||||
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
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 { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||
|
||||
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}`;
|
||||
|
||||
setOfflineSettings({
|
||||
item,
|
||||
});
|
||||
setPlayUrl(url);
|
||||
|
||||
router.push("/play-offline-video");
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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,109 @@
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
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, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
|
||||
export const useImageColors = (
|
||||
uri: string | undefined | null,
|
||||
disabled = false
|
||||
) => {
|
||||
/**
|
||||
* Custom hook to extract and manage image colors for a given item.
|
||||
*
|
||||
* @param item - The BaseItemDto object representing the item.
|
||||
* @param disabled - A boolean flag to disable color extraction.
|
||||
*
|
||||
*/
|
||||
export const useImageColors = ({
|
||||
item,
|
||||
url,
|
||||
disabled,
|
||||
}: {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (url) return { uri: url };
|
||||
else if (item)
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
else return null;
|
||||
}, [api, item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
if (uri) {
|
||||
getColors(uri, {
|
||||
if (source?.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",
|
||||
cache: true,
|
||||
key: uri,
|
||||
cache: false,
|
||||
})
|
||||
.then((colors) => {
|
||||
let primary: string = "#fff";
|
||||
let average: string = "#fff";
|
||||
let secondary: string = "#fff";
|
||||
let text: string = "#000";
|
||||
let backup: string = "#fff";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
average = colors.average;
|
||||
secondary = colors.muted;
|
||||
backup = colors.vibrant;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.primary;
|
||||
secondary = colors.detail;
|
||||
average = colors.background;
|
||||
primary = colors.detail;
|
||||
backup = colors.primary;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
secondary,
|
||||
average,
|
||||
text,
|
||||
});
|
||||
|
||||
// Cache the colors in storage
|
||||
if (source.uri && primary) {
|
||||
storage.set(`${source.uri}-primary`, primary);
|
||||
storage.set(`${source.uri}-text`, text);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [uri, setPrimaryColor, disabled]);
|
||||
}, [source?.uri, setPrimaryColor, disabled]);
|
||||
};
|
||||
|
||||
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;
|
||||
73
hooks/useIntroSkipper.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (introTimestamps) {
|
||||
setShowSkipButton(
|
||||
currentTime > introTimestamps.ShowSkipPromptAt &&
|
||||
currentTime < introTimestamps.HideSkipPromptAt
|
||||
);
|
||||
}
|
||||
}, [introTimestamps, currentTime]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
console.log("skipIntro");
|
||||
if (!introTimestamps || !videoRef.current) return;
|
||||
try {
|
||||
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||
setTimeout(() => {
|
||||
videoRef.current?.resume();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error skipping intro", error);
|
||||
}
|
||||
}, [introTimestamps, videoRef]);
|
||||
|
||||
return { showSkipButton, skipIntro };
|
||||
};
|
||||
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]);
|
||||
};
|
||||
28
hooks/useOrientation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useOrientation = () => {
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.OrientationLock.UNKNOWN
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const orientationSubscription =
|
||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
||||
setOrientation(
|
||||
orientationToOrientationLock(event.orientationInfo.orientation)
|
||||
);
|
||||
});
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
||||
setOrientation(orientationToOrientationLock(orientation));
|
||||
});
|
||||
|
||||
return () => {
|
||||
orientationSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { orientation };
|
||||
};
|
||||
25
hooks/useOrientationSettings.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useOrientationSettings = () => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate) {
|
||||
// Don't need to do anything
|
||||
} else if (settings?.defaultVideoOrientation) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (settings?.autoRotate) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [settings]);
|
||||
};
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
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";
|
||||
import useImageStorage from "./useImageStorage";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -16,8 +22,12 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const [_, setProgress] = useAtom(runningProcesses);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||
const router = useRouter();
|
||||
const { loadImage, saveImage, image2Base64, saveBase64Image } =
|
||||
useImageStorage();
|
||||
|
||||
if (!item.Id || !item.Name) {
|
||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||
@@ -28,6 +38,29 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (url: string) => {
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
|
||||
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}`;
|
||||
|
||||
writeToLog(
|
||||
@@ -36,7 +69,20 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
);
|
||||
|
||||
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) => {
|
||||
const videoLength =
|
||||
@@ -51,11 +97,19 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev
|
||||
);
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
setProcesses((prev) => {
|
||||
return prev.map((process) => {
|
||||
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.
|
||||
@@ -65,11 +119,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
if (!item) throw new Error("Item is undefined");
|
||||
await saveDownloadedItemInfo(item);
|
||||
toast.success("Download completed");
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
||||
);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
@@ -85,63 +144,35 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`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
|
||||
}
|
||||
},
|
||||
[output, item, setProgress]
|
||||
[output, item]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProgress(null);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
|
||||
);
|
||||
}, [item.Name, setProgress]);
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
}, [item.Name]);
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||