mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa785b0f95 | ||
|
|
8ea38a3efc | ||
|
|
07ad905e16 | ||
|
|
3058b866c3 | ||
|
|
497a1adc26 | ||
|
|
093cd94455 | ||
|
|
349a86bcfb | ||
|
|
4b81dff0be | ||
|
|
ee6d43e3e8 | ||
|
|
f8d22bb7d6 | ||
|
|
a0391b484d | ||
|
|
681aadb121 | ||
|
|
479a1f037e | ||
|
|
ae5b88ab56 | ||
|
|
9091b9b66a | ||
|
|
cccb26c9cc | ||
|
|
28568cbb9c | ||
|
|
8344d4025b | ||
|
|
0f69448081 | ||
|
|
a936916da4 | ||
|
|
c753e33f38 | ||
|
|
48422fa93e | ||
|
|
5adf943fd9 | ||
|
|
9174a8104d | ||
|
|
56f1bd489c | ||
|
|
5e79b5a581 | ||
|
|
36a689f59d | ||
|
|
47211ba009 | ||
|
|
e86a2af9a9 | ||
|
|
c46b4cc34d | ||
|
|
ec0d9d7788 | ||
|
|
d2eda1365c | ||
|
|
b58fa86a6b | ||
|
|
400dfe3679 | ||
|
|
cf58a5e749 | ||
|
|
001eba02b4 | ||
|
|
67e767f298 | ||
|
|
5f1c5f7b34 | ||
|
|
e54cac1e09 | ||
|
|
cbce83e109 | ||
|
|
c6b58c5c28 | ||
|
|
0468756317 | ||
|
|
9f12ee027f | ||
|
|
78b7425c6b | ||
|
|
c38c1d06ad | ||
|
|
5af735065a | ||
|
|
600276cb69 | ||
|
|
ba3104f87e |
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,13 +1,13 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: '[Bug]: '
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- ['❌ bug']
|
||||
projects:
|
||||
- ['fredrikburmester/5']
|
||||
- ["❌ bug"]
|
||||
projects:
|
||||
- ["fredrikburmester/5"]
|
||||
assignees:
|
||||
- fredrikburmester
|
||||
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
@@ -43,8 +43,9 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.23.0
|
||||
- 0.22.0
|
||||
- 0.21.0
|
||||
- older
|
||||
@@ -54,6 +55,5 @@ body:
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label:
|
||||
If applicable, please add screenshots to help explain your problem.
|
||||
label: If applicable, please add screenshots to help explain your problem.
|
||||
You can drag and drop images here or paste them directly into the comment box.
|
||||
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[submodule "utils/jellyseerr"]
|
||||
path = utils/jellyseerr
|
||||
url = https://github.com/herrrta/jellyseerr
|
||||
branch = models
|
||||
43
README.md
43
README.md
@@ -15,10 +15,10 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
- 🚀 **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.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
@@ -70,11 +70,9 @@ Or download the APKs [here on GitHub](https://github.com/fredrikburmester/stream
|
||||
|
||||
### Beta testing
|
||||
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||
|
||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||
</a>
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -89,36 +87,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
||||
|
||||
### Development info
|
||||
|
||||
1. Use node `20`
|
||||
2. Install dependencies `bun i`
|
||||
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||
|
||||
## Extended chromecast controls
|
||||
|
||||
Add this to AppDelegate.mm:
|
||||
|
||||
```
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||
{
|
||||
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
|
||||
#if __has_include(<GoogleCast/GoogleCast.h>)
|
||||
...
|
||||
|
||||
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;`
|
||||
#endif
|
||||
```
|
||||
|
||||
Add this to Info.plist:
|
||||
|
||||
```
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
|
||||
```
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed.
|
||||
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
|
||||
|
||||
## 📄 License
|
||||
|
||||
@@ -153,6 +125,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.
|
||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## Star History
|
||||
|
||||
51
app.json
51
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -36,38 +36,22 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 47,
|
||||
"versionCode": 49,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
]
|
||||
"permissions": []
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
"enableNotificationControls": true,
|
||||
"enableBackgroundAudio": true,
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
"enableBackgroundAudio": true
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -76,29 +60,9 @@
|
||||
"ios": {
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
},
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
"jniLibs": {
|
||||
"useLegacyPackaging": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
"initialOrientation": "DEFAULT"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-sensors",
|
||||
{
|
||||
@@ -106,12 +70,9 @@
|
||||
}
|
||||
],
|
||||
"expo-asset",
|
||||
[
|
||||
"react-native-edge-to-edge",
|
||||
{ "android": { "parentTheme": "Material3" } }
|
||||
],
|
||||
["react-native-edge-to-edge"],
|
||||
["react-native-bottom-tabs"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
||||
["@react-native-tvos/config-tv"]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import {Stack} from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function CustomMenuLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Custom Links",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {FlatList, TouchableOpacity, View} from "react-native";
|
||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||
import React, {useCallback, useEffect, useState} from "react";
|
||||
import {useAtom} from "jotai/index";
|
||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||
import {ListItem} from "@/components/ListItem";
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import {Text} from "@/components/common/Text";
|
||||
|
||||
export interface MenuLink {
|
||||
name: string,
|
||||
url: string,
|
||||
icon: string
|
||||
}
|
||||
|
||||
export default function menuLinks() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets()
|
||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
||||
|
||||
const getMenuLinks = useCallback(async () => {
|
||||
try {
|
||||
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
||||
const config = response?.data;
|
||||
|
||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||
console.error("Menu links not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setMenuLinks(config?.menuLinks as MenuLink[])
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve config:", error);
|
||||
}
|
||||
},
|
||||
[api]
|
||||
)
|
||||
|
||||
useEffect(() => { getMenuLinks() }, []);
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 10,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={menuLinks}
|
||||
renderItem={({item}) => (
|
||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
||||
<ListItem
|
||||
title={item.name}
|
||||
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
@@ -19,7 +18,6 @@ export default function IndexLayout() {
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
@@ -31,18 +29,6 @@ export default function IndexLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/index"
|
||||
options={{
|
||||
title: "Downloads",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/[seriesId]"
|
||||
options={{
|
||||
title: "TV-Series",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const local = useLocalSearchParams();
|
||||
const { seriesId, episodeSeasonIndex } = local as {
|
||||
seriesId: string;
|
||||
episodeSeasonIndex: number | string | undefined;
|
||||
};
|
||||
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{}
|
||||
);
|
||||
const { downloadedFiles, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
downloadedFiles
|
||||
?.filter((f) => f.item.SeriesId == seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||
episodeSeasonIndex ||
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
|
||||
series?.forEach((episode) => {
|
||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||
seasons[episode.item.ParentIndexNumber!] = [];
|
||||
}
|
||||
|
||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||
});
|
||||
return (
|
||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||
[]
|
||||
);
|
||||
}, [series, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (series.length > 0) {
|
||||
navigation.setOptions({
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
} else {
|
||||
storage.delete(seriesId);
|
||||
router.back();
|
||||
}
|
||||
}, [series]);
|
||||
|
||||
const deleteSeries = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Delete season",
|
||||
"Are you sure you want to delete the entire season?",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
onPress: () => deleteItems(groupBySeason),
|
||||
style: "destructive",
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [groupBySeason]);
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{series.length > 0 && (
|
||||
<View className="flex flex-row items-center justify-start my-2 px-4">
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={series.map((s) => s.item)}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name="trash" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView key={seasonIndex} className="px-4">
|
||||
{groupBySeason.map((episode, index) => (
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
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 { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {useNavigation, useRouter} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {useEffect, useMemo, useRef} from "react";
|
||||
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||
import {toast} from "sonner-native";
|
||||
import {writeToLog} from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
try {
|
||||
const episodes = downloadedFiles?.filter(
|
||||
(f) => f.item.Type === "Episode"
|
||||
);
|
||||
const series: { [key: string]: DownloadedItem[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||
series[e.item.SeriesName!].push(e);
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={bottomSheetModalRef.current?.present}
|
||||
>
|
||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const deleteMovies = () => deleteFileByType("Movie")
|
||||
.then(() => toast.success("Deleted all movies successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all movies");
|
||||
});
|
||||
const deleteShows = () => deleteFileByType("Episode")
|
||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error("Failed to delete all TV-Series");
|
||||
});
|
||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<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, index) => (
|
||||
<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"
|
||||
key={index}
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</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 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>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{movies?.map((item) => (
|
||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||
<MovieCard item={item.item}/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">TV-Series</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className="flex px-4">
|
||||
<Text className="opacity-50">No downloaded items</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="p-4 space-y-4 mb-4">
|
||||
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
||||
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
||||
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function migration_20241124() {
|
||||
const router = useRouter();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
Alert.alert(
|
||||
"New app version requires re-download",
|
||||
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||
[
|
||||
{
|
||||
text: "Back",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => await deleteAllFiles(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
@@ -64,31 +63,10 @@ export default function index() {
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||
|
||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
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();
|
||||
@@ -107,9 +85,6 @@ export default function index() {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
cleanCacheDirectory()
|
||||
.then(r => console.log("Cache directory cleaned"))
|
||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
@@ -308,48 +283,6 @@ export default function index() {
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, mediaListCollections]);
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
No worries, you can still watch{"\n"}downloaded content.
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
Go to downloads
|
||||
</Button>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
checkConnection();
|
||||
}}
|
||||
justify="center"
|
||||
className="mt-2"
|
||||
iconRight={
|
||||
loadingRetry ? null : (
|
||||
<Ionicons name="refresh" size={20} color="white" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{loadingRetry ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
"Retry"
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (e1 || e2)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
|
||||
@@ -2,13 +2,11 @@ import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs, useLog } from "@/utils/log";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import * as Progress from "react-native-progress";
|
||||
@@ -17,7 +15,6 @@ import { toast } from "sonner-native";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { logs } = useLog();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -25,18 +22,6 @@ export default function settings() {
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
||||
queryKey: ["appSize", appSizeUsage],
|
||||
queryFn: async () => {
|
||||
const app = await appSizeUsage;
|
||||
|
||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
||||
|
||||
return { app, remaining, total, used: (total - remaining) / total };
|
||||
},
|
||||
});
|
||||
|
||||
const openQuickConnectAuthCodeInput = () => {
|
||||
Alert.prompt(
|
||||
"Quick connect",
|
||||
@@ -49,16 +34,11 @@ export default function settings() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -66,21 +46,6 @@ export default function settings() {
|
||||
);
|
||||
};
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
toast.error("Error deleting files");
|
||||
}
|
||||
};
|
||||
|
||||
const onClearLogsClicked = async () => {
|
||||
clearLogs();
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -90,13 +55,6 @@ 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">User Info</Text>
|
||||
|
||||
@@ -119,34 +77,6 @@ export default function settings() {
|
||||
|
||||
<SettingToggles />
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="font-bold text-lg mb-2">Storage</Text>
|
||||
<View className="mb-4 space-y-2">
|
||||
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
|
||||
<Progress.Bar
|
||||
className="bg-gray-100/10"
|
||||
indeterminate={appSizeLoading}
|
||||
color="#9333ea"
|
||||
width={null}
|
||||
height={10}
|
||||
borderRadius={6}
|
||||
borderWidth={0}
|
||||
progress={size?.used}
|
||||
/>
|
||||
{size && (
|
||||
<Text>
|
||||
Available: {bytesToReadable(size.remaining)}, Total:{" "}
|
||||
{bytesToReadable(size.total)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Button color="red" onPress={onDeleteClicked}>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button color="red" onPress={onClearLogsClicked}>
|
||||
Delete all logs
|
||||
</Button>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -28,16 +27,6 @@ export default function page() {
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Chromecast />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const { data: album } = useQuery({
|
||||
queryKey: ["album", albumId, artistId],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
@@ -41,10 +40,6 @@ const page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
@@ -104,9 +99,12 @@ const page: React.FC = () => {
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
@@ -171,8 +169,7 @@ const page: React.FC = () => {
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
@@ -386,9 +383,7 @@ const page: React.FC = () => {
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
numColumns={5}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
|
||||
320
app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx
Normal file
320
app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Modal, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequestString,
|
||||
posterSrc,
|
||||
...result
|
||||
} = params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
} & Partial<MovieResult | TvResult>;
|
||||
|
||||
const canRequest = canRequestString === "true";
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [isIssueTypeModalVisible, setIsIssueTypeModalVisible] = useState(false);
|
||||
|
||||
const {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
},
|
||||
});
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
if (result.id && issueType && issueMessage && details) {
|
||||
jellyseerrApi
|
||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||
.then(() => {
|
||||
setIssueType(undefined);
|
||||
setIssueMessage(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
});
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const request = useCallback(
|
||||
() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}),
|
||||
[details, result, requestMedia]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<View>
|
||||
{result.backdropPath ? (
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<View className="space-y-4">
|
||||
<View className="px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-56">
|
||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className="font-bold text-2xl mb-1"
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className="opacity-50">{releaseYear}</Text>
|
||||
</View>
|
||||
<Image
|
||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
source={{
|
||||
uri: posterSrc,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{canRequest ? (
|
||||
<Button color="purple" onPress={request}>
|
||||
Request
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
||||
color="transparent"
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons name="warning-outline" size={24} color="white" />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
Report issue
|
||||
</Button>
|
||||
)}
|
||||
<OverviewText text={result.overview} className="mt-4" />
|
||||
</View>
|
||||
|
||||
{result.mediaType === MediaType.TV && (
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
result={result as TvResult}
|
||||
details={details as TvDetails}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
Whats wrong?
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 items-start">
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">Issue Type</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"
|
||||
onPress={() => setIsIssueTypeModalVisible(true)}
|
||||
>
|
||||
<Text className="" numberOfLines={1}>
|
||||
{issueType ? IssueTypeName[issueType] : "Select an issue"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isIssueTypeModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsIssueTypeModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsIssueTypeModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Issue Type
|
||||
</Text>
|
||||
</View>
|
||||
<View className="max-h-[50%]">
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value]) => (
|
||||
<TouchableOpacity
|
||||
key={key}
|
||||
className="p-4 border-b border-neutral-800"
|
||||
onPress={() => {
|
||||
setIssueType(key as unknown as IssueType);
|
||||
setIsIssueTypeModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text className="text-center">{value}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsIssueTypeModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder="(optional) Describe the issue..."
|
||||
value={issueMessage}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="none"
|
||||
maxLength={254}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||
Submit
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
@@ -70,35 +69,10 @@ const page: React.FC = () => {
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
!isLoading &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadItems
|
||||
title="Download Series"
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [allEpisodes, isLoading]);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
@@ -133,10 +107,7 @@ const page: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
</View>
|
||||
<SeriesHeader item={item} />
|
||||
<View className="mb-4">
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
@@ -12,7 +11,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
@@ -61,8 +59,6 @@ const Page = () => {
|
||||
sortOrderPreferenceAtom
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
useEffect(() => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sop) {
|
||||
@@ -141,8 +137,6 @@ const Page = () => {
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
console.log("[libraryId] ~", library);
|
||||
|
||||
let itemType: BaseItemKind | undefined;
|
||||
|
||||
// This fix makes sure to only return 1 type of items, if defined.
|
||||
@@ -151,6 +145,8 @@ const Page = () => {
|
||||
itemType = "Movie";
|
||||
} else if (library.CollectionType === "tvshows") {
|
||||
itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
itemType = "BoxSet";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
@@ -161,7 +157,8 @@ const Page = () => {
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: false,
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
@@ -240,14 +237,7 @@ const Page = () => {
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
? index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
alignSelf: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { useState } from "react";
|
||||
import { Modal, Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
|
||||
const MenuItem = ({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text className="text-base">{label}</Text>
|
||||
{selected && <Ionicons name="checkmark" size={24} color="white" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const MenuSection = ({ title }: { title: string }) => (
|
||||
<View className="p-4 border-b border-neutral-800 bg-neutral-800/30">
|
||||
<Text className="text-sm opacity-50 font-medium">{title}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
@@ -22,163 +54,167 @@ export default function IndexLayout() {
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align={"end"}
|
||||
alignOffset={-10}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
<Modal
|
||||
visible={isMenuVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => {
|
||||
setIsMenuVisible(false);
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => {
|
||||
setIsMenuVisible(false);
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
||||
<DropdownMenu.Group key="display-group">
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Display
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-1"
|
||||
value={settings.libraryOptions.display === "row"}
|
||||
onValueChange={() =>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
{!activeSubmenu ? (
|
||||
<>
|
||||
<MenuSection title="Display" />
|
||||
<MenuItem
|
||||
label="Display"
|
||||
onPress={() => setActiveSubmenu("display")}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Image style"
|
||||
onPress={() => setActiveSubmenu("imageStyle")}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Show titles"
|
||||
selected={settings.libraryOptions.showTitles}
|
||||
disabled={
|
||||
settings.libraryOptions.imageStyle === "poster"
|
||||
}
|
||||
onPress={() => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: !settings.libraryOptions.showTitles,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Show stats"
|
||||
selected={settings.libraryOptions.showStats}
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: !settings.libraryOptions.showStats,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : activeSubmenu === "display" ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">Display</Text>
|
||||
</View>
|
||||
<MenuItem
|
||||
label="Row"
|
||||
selected={settings.libraryOptions.display === "row"}
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-1">
|
||||
Row
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-2"
|
||||
value={settings.libraryOptions.display === "list"}
|
||||
onValueChange={() =>
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="List"
|
||||
selected={settings.libraryOptions.display === "list"}
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
})
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : activeSubmenu === "imageStyle" ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">
|
||||
Image Style
|
||||
</Text>
|
||||
</View>
|
||||
<MenuItem
|
||||
label="Poster"
|
||||
selected={
|
||||
settings.libraryOptions.imageStyle === "poster"
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-2">
|
||||
List
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Image style
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="poster-option"
|
||||
value={settings.libraryOptions.imageStyle === "poster"}
|
||||
onValueChange={() =>
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
})
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Cover"
|
||||
selected={
|
||||
settings.libraryOptions.imageStyle === "cover"
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="poster-title">
|
||||
Poster
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="cover-option"
|
||||
value={settings.libraryOptions.imageStyle === "cover"}
|
||||
onValueChange={() =>
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="cover-title">
|
||||
Cover
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group key="show-titles-group">
|
||||
<DropdownMenu.CheckboxItem
|
||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||
key="show-titles-option"
|
||||
value={settings.libraryOptions.showTitles}
|
||||
onValueChange={(newValue) => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||
Show titles
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="show-stats-option"
|
||||
value={settings.libraryOptions.showStats}
|
||||
onValueChange={(newValue) => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||
Show stats
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Group>
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => {
|
||||
setIsMenuVisible(false);
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
@@ -29,6 +29,10 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/page"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import axios from "axios";
|
||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -29,6 +30,15 @@ import React, {
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
@@ -45,6 +55,7 @@ export default function search() {
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
@@ -53,6 +64,7 @@ export default function search() {
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
@@ -132,9 +144,51 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
const response = await jellyseerrApi?.search({
|
||||
query: new URLSearchParams(debouncedSearch).toString(),
|
||||
page: 1, // todo: maybe rework page & page-size if first results are not enough...
|
||||
language: "en",
|
||||
});
|
||||
|
||||
return response?.results;
|
||||
},
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length == 0,
|
||||
});
|
||||
|
||||
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE
|
||||
) as MovieResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.TV
|
||||
) as TvResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
queryKey: ["search", "series", debouncedSearch],
|
||||
queryFn: () =>
|
||||
@@ -142,7 +196,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: episodes, isFetching: l3 } = useQuery({
|
||||
@@ -152,7 +206,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: collections, isFetching: l7 } = useQuery({
|
||||
@@ -162,7 +216,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: actors, isFetching: l8 } = useQuery({
|
||||
@@ -172,7 +226,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Person"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: artists, isFetching: l4 } = useQuery({
|
||||
@@ -182,7 +236,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isFetching: l5 } = useQuery({
|
||||
@@ -192,7 +246,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isFetching: l6 } = useQuery({
|
||||
@@ -202,7 +256,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
@@ -214,13 +268,25 @@ export default function search() {
|
||||
episodes?.length ||
|
||||
series?.length ||
|
||||
collections?.length ||
|
||||
actors?.length
|
||||
actors?.length ||
|
||||
jellyseerrMovieResults?.length ||
|
||||
jellyseerrTvResults?.length
|
||||
);
|
||||
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
||||
}, [
|
||||
artists,
|
||||
episodes,
|
||||
albums,
|
||||
songs,
|
||||
movies,
|
||||
series,
|
||||
collections,
|
||||
actors,
|
||||
jellyseerrResults,
|
||||
]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
||||
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
|
||||
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -245,6 +311,28 @@ export default function search() {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{jellyseerrApi && (
|
||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text="Library"
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Library" ? "bg-neutral-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text="Discover"
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Discover" ? "bg-neutral-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{!!q && (
|
||||
<View className="px-4 flex flex-col space-y-2">
|
||||
<Text className="text-neutral-500 ">
|
||||
@@ -252,130 +340,153 @@ export default function search() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
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={(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={(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={(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={(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={(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={(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={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
{searchType === "Library" && (
|
||||
<>
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<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={(item: BaseItemDto) => (
|
||||
<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={(item: BaseItemDto) => (
|
||||
<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={(item: BaseItemDto) => (
|
||||
<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={(item: BaseItemDto) => (
|
||||
<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={(item: BaseItemDto) => (
|
||||
<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={(item: BaseItemDto) => (
|
||||
<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={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{searchType === "Discover" && (
|
||||
<>
|
||||
<SearchItemWrapper
|
||||
header="Request Movies"
|
||||
items={jellyseerrMovieResults}
|
||||
renderItem={(item: MovieResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header="Request Series"
|
||||
items={jellyseerrTvResults}
|
||||
renderItem={(item: TvResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<View className="mt-4 flex justify-center items-center">
|
||||
<Loader />
|
||||
@@ -389,7 +500,7 @@ export default function search() {
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
) : debouncedSearch.length === 0 && searchType === "Library" ? (
|
||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
@@ -401,6 +512,15 @@ export default function search() {
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
||||
<View className="flex flex-col px-4">
|
||||
{sortBy?.(
|
||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||
"order"
|
||||
).map((slide) => (
|
||||
<DiscoverSlide key={slide.id} slide={slide} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -408,13 +528,19 @@ export default function search() {
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type Props<T> = {
|
||||
ids?: string[] | null;
|
||||
renderItem: (item: BaseItemDto) => React.ReactNode;
|
||||
items?: T[];
|
||||
renderItem: (item: any) => React.ReactNode;
|
||||
header?: string;
|
||||
};
|
||||
|
||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||
const SearchItemWrapper = <T extends unknown>({
|
||||
ids,
|
||||
items,
|
||||
renderItem,
|
||||
header,
|
||||
}: PropsWithChildren<Props<T>>) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -444,7 +570,7 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
if (!data && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -454,7 +580,11 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||
className="px-4 mb-2"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
{data.map((item) => renderItem(item))}
|
||||
{data && data?.length > 0
|
||||
? data.map((item) => renderItem(item))
|
||||
: items && items?.length > 0
|
||||
? items.map((i) => renderItem(i))
|
||||
: undefined}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -73,18 +73,6 @@ export default function TabLayout() {
|
||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(custom-links)"
|
||||
options={{
|
||||
title: "Custom Links",
|
||||
// @ts-expect-error
|
||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? () => require("@/assets/icons/list.png")
|
||||
: () => ({ sfSymbol: "list.dash" }),
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||
@@ -13,8 +10,8 @@ import {
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
@@ -27,27 +24,17 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
BackHandler,
|
||||
View,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import settings from "../(tabs)/(home)/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
@@ -65,12 +52,10 @@ export default function page() {
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const { getDownloadedItem } = useDownload();
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
@@ -79,17 +64,14 @@ export default function page() {
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
} = useGlobalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
offline: string;
|
||||
}>();
|
||||
const [settings] = useSettings();
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||
@@ -104,12 +86,6 @@ export default function page() {
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const item = await getDownloadedItem(itemId);
|
||||
if (item) return item.item;
|
||||
}
|
||||
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
@@ -128,21 +104,6 @@ export default function page() {
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return null;
|
||||
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
|
||||
if (item)
|
||||
return {
|
||||
mediaSource: data.mediaSource,
|
||||
url,
|
||||
sessionId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
@@ -177,11 +138,10 @@ export default function page() {
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!api) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
|
||||
if (!offline && stream) {
|
||||
if (stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -199,7 +159,7 @@ export default function page() {
|
||||
console.log("Actually marked as paused");
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
if (!offline && stream) {
|
||||
if (stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -223,13 +183,10 @@ export default function page() {
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
offline,
|
||||
progress.value,
|
||||
]);
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.value);
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
@@ -250,8 +207,6 @@ export default function page() {
|
||||
|
||||
// TODO: unused should remove.
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
if (!stream) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id!,
|
||||
@@ -276,19 +231,10 @@ export default function page() {
|
||||
|
||||
progress.value = currentTime;
|
||||
|
||||
if (offline) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(currentTime);
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
currentTimeInTicks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -303,14 +249,10 @@ export default function page() {
|
||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline,
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||
@@ -335,8 +277,6 @@ export default function page() {
|
||||
}, []);
|
||||
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
|
||||
return item?.UserData?.PlaybackPositionTicks
|
||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||
: 0;
|
||||
@@ -351,37 +291,6 @@ export default function page() {
|
||||
}, [])
|
||||
);
|
||||
|
||||
const [appState, setAppState] = useState(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||
console.log("App has come to the foreground!");
|
||||
// Handle app coming to the foreground
|
||||
} else if (nextAppState.match(/inactive|background/)) {
|
||||
console.log("App has gone to the background!");
|
||||
// Handle app going to the background
|
||||
if (videoRef.current && videoRef.current.pause) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}
|
||||
setAppState(nextAppState);
|
||||
};
|
||||
|
||||
// Use AppState.addEventListener and return a cleanup function
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange
|
||||
);
|
||||
|
||||
return () => {
|
||||
// Cleanup the event listener when the component is unmounted
|
||||
subscription.remove();
|
||||
};
|
||||
}, [appState]);
|
||||
|
||||
// Preselection of audio and subtitle tracks.
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
@@ -502,7 +411,6 @@ export default function page() {
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
@@ -124,7 +121,6 @@ export default function page() {
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
@@ -261,9 +257,6 @@ export default function page() {
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
@@ -20,7 +18,6 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
@@ -58,7 +55,6 @@ const Player = () => {
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}, []);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
@@ -167,7 +163,6 @@ const Player = () => {
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
@@ -299,14 +294,10 @@ const Player = () => {
|
||||
]
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline: false,
|
||||
});
|
||||
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||
|
||||
46
app/(auth)/trailer/page.tsx
Normal file
46
app/(auth)/trailer/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Dimensions, View } from "react-native";
|
||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useGlobalSearchParams();
|
||||
console.log(searchParams);
|
||||
|
||||
const { url } = searchParams as { url: string };
|
||||
|
||||
const videoId = useMemo(() => {
|
||||
return url.split("v=")[1];
|
||||
}, [url]);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||
if (state === "ended") {
|
||||
setPlaying(false);
|
||||
Alert.alert("video has finished playing!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlaying = useCallback(() => {
|
||||
setPlaying((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
togglePlaying();
|
||||
}, []);
|
||||
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||
<YoutubePlayer
|
||||
height={300}
|
||||
play={playing}
|
||||
videoId={videoId}
|
||||
onChangeState={onStateChange}
|
||||
width={screenWidth}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
400
app/_layout.tsx
400
app/_layout.tsx
@@ -1,41 +1,19 @@
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import "@/augmentations";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { LogProvider, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { LogProvider } from "@/utils/log";
|
||||
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 { 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 * 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 { Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
@@ -43,170 +21,6 @@ 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 = storage.getString("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 = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
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;
|
||||
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: downloadUrl,
|
||||
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 = storage.getString(
|
||||
"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.");
|
||||
}
|
||||
|
||||
storage.set("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"),
|
||||
@@ -244,151 +58,75 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||
|
||||
useKeepAwake();
|
||||
useNotificationObserver();
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate === true)
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
else
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
}, [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) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const url = Linking.useURL();
|
||||
|
||||
if (url) {
|
||||
const { hostname, path, queryParams } = Linking.parse(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActionSheetProvider>
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/trailer/page"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</ActionSheetProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("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);
|
||||
}
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
65
assets/images/not-rotten-tomatoes.svg
Normal file
65
assets/images/not-rotten-tomatoes.svg
Normal file
@@ -0,0 +1,65 @@
|
||||
<svg
|
||||
type="certified"
|
||||
viewBox="0 0 80 80"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g transform="translate(2.29, 0)">
|
||||
<path
|
||||
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||
id="Fill-2"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<mask id="mask-2" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||
fill="#FFD700"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||
fill="#FA6E0F"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
<g transform="translate(0, 20.57)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||
fill="#FA3200"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
</g>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
3
augmentations/index.ts
Normal file
3
augmentations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
17
augmentations/mmkv.ts
Normal file
17
augmentations/mmkv.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {MMKV} from "react-native-mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
get<T>(key: string): T | undefined
|
||||
setAny(key: string, value: any | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
||||
const serializedItem = this.getString(key);
|
||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||
}
|
||||
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
37
augmentations/number.ts
Normal file
37
augmentations/number.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
declare global {
|
||||
interface Number {
|
||||
bytesToReadable(): string;
|
||||
secondsToMilliseconds(): number
|
||||
minutesToMilliseconds(): number
|
||||
hoursToMilliseconds(): number
|
||||
}
|
||||
}
|
||||
|
||||
Number.prototype.bytesToReadable = function () {
|
||||
const bytes = this.valueOf();
|
||||
const gb = bytes / 1e9;
|
||||
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
|
||||
const mb = bytes / 1024.0 / 1024.0;
|
||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||
|
||||
const kb = bytes / 1024.0;
|
||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||
|
||||
return `${bytes.toFixed(2)} B`;
|
||||
}
|
||||
|
||||
Number.prototype.secondsToMilliseconds = function () {
|
||||
return this.valueOf() * 1000
|
||||
}
|
||||
|
||||
Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds()
|
||||
}
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds()
|
||||
}
|
||||
|
||||
export {};
|
||||
16
augmentations/string.ts
Normal file
16
augmentations/string.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
declare global {
|
||||
interface String {
|
||||
toTitle(): string;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this
|
||||
.replaceAll("_", " ")
|
||||
.replace(
|
||||
/\w\S*/g,
|
||||
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
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 { useMemo, useState } from "react";
|
||||
import { Modal, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
@@ -16,6 +16,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
@@ -25,50 +27,80 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio</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 className="" numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
<>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio</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"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text className="" numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Audio Streams
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{audioStreams?.map((audio, idx: number) => (
|
||||
<TouchableOpacity
|
||||
key={idx.toString()}
|
||||
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between`}
|
||||
onPress={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined) {
|
||||
onChange(audio.Index);
|
||||
setIsModalVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>{audio.DisplayTitle}</Text>
|
||||
{audio.Index === selected && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
||||
{audioStreams?.map((audio, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined)
|
||||
onChange(audio.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{audio.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { TouchableOpacity, View, Modal } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
@@ -49,6 +49,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.sort(
|
||||
@@ -57,49 +59,81 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
return BITRATES.sort(
|
||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||
);
|
||||
}, []);
|
||||
}, [inverted]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...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}
|
||||
<>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col" {...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"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text className="" numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Quality
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{sorted.map((bitrate) => (
|
||||
<TouchableOpacity
|
||||
key={bitrate.key}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onChange(bitrate);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{bitrate.key}</Text>
|
||||
{bitrate.value === selected?.value && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="center"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{sorted.map((b) => (
|
||||
<DropdownMenu.Item
|
||||
key={b.key}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
@@ -47,14 +46,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
<TouchableOpacity
|
||||
className={`
|
||||
p-3 rounded-xl items-center justify-center
|
||||
${loading || (disabled && "opacity-50")}
|
||||
${(loading || disabled) && "opacity-50"}
|
||||
${colorClasses}
|
||||
${className}
|
||||
`}
|
||||
onPress={() => {
|
||||
if (!loading && !disabled && onPress) {
|
||||
onPress();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
background?: "blur" | "transparent";
|
||||
}
|
||||
|
||||
export const Chromecast: React.FC<Props> = ({
|
||||
width = 48,
|
||||
height = 48,
|
||||
background = "transparent",
|
||||
...props
|
||||
}) => {
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor="transparent" />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS]
|
||||
);
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<RoundButton
|
||||
size="large"
|
||||
className="mr-2"
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundButton
|
||||
size="large"
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
};
|
||||
@@ -1,405 +0,0 @@
|
||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import download from "@/utils/profiles/download";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Href, router, useFocusEffect } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, 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 { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
MissingDownloadIconComponent: () => React.ReactElement;
|
||||
DownloadedIconComponent: () => React.ReactElement;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
items,
|
||||
MissingDownloadIconComponent,
|
||||
DownloadedIconComponent,
|
||||
title = "Download",
|
||||
subtitle = "",
|
||||
size = "default",
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user]
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === "optimized",
|
||||
[settings]
|
||||
);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
const itemsNotDownloaded = useMemo(
|
||||
() =>
|
||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||
[items, downloadedFiles]
|
||||
);
|
||||
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return itemsNotDownloaded.length === 0;
|
||||
}, [items, itemsNotDownloaded]);
|
||||
const itemsProcesses = useMemo(
|
||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||
[processes, itemIds]
|
||||
);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (itemIds.length == 1)
|
||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||
return (
|
||||
((itemIds.length -
|
||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||
itemIds.length) *
|
||||
100
|
||||
);
|
||||
}, [queue, itemsProcesses, itemIds]);
|
||||
|
||||
const itemsQueued = useMemo(() => {
|
||||
return (
|
||||
itemsNotDownloaded.length > 0 &&
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||
);
|
||||
}, [queue, itemsNotDownloaded]);
|
||||
const navigateToDownloads = () => router.push("/downloads");
|
||||
|
||||
const onDownloadedPress = () => {
|
||||
const firstItem = items?.[0];
|
||||
router.push(
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href)
|
||||
);
|
||||
};
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||
else {
|
||||
queueActions.enqueue(
|
||||
queue,
|
||||
setQueue,
|
||||
...itemsNotDownloaded.map((item) => ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error("You are not allowed to download files.");
|
||||
}
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
]);
|
||||
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
);
|
||||
}
|
||||
let mediaSource = selectedMediaSource;
|
||||
let audioIndex: number | undefined = selectedAudioStream;
|
||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||
item,
|
||||
settings!
|
||||
));
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
Alert.alert(
|
||||
"Something went wrong",
|
||||
"Could not get stream url from Jellyfin"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { mediaSource: source, url } = res;
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
|
||||
if (usingOptimizedServer) {
|
||||
await startBackgroundDownload(url, item, source);
|
||||
} else {
|
||||
await startRemuxing(item, url, source);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
itemsNotDownloaded,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
startRemuxing,
|
||||
]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, itemsNotDownloaded, settings])
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (itemsQueued) {
|
||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
||||
} else if (allItemsDownloaded) {
|
||||
return <DownloadedIconComponent />;
|
||||
} else {
|
||||
return <MissingDownloadIconComponent />;
|
||||
}
|
||||
};
|
||||
|
||||
const onButtonPress = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
navigateToDownloads();
|
||||
} else if (itemsQueued) {
|
||||
navigateToDownloads();
|
||||
} else if (allItemsDownloaded) {
|
||||
onDownloadedPress();
|
||||
} else {
|
||||
handlePresentModalPress();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton size={size} onPress={onButtonPress}>
|
||||
{renderButtonContent()}
|
||||
</RoundButton>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-neutral-300">
|
||||
{subtitle || `Download ${itemsNotDownloaded.length} items`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 w-full items-start">
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={acceptDownloadOptions}
|
||||
color="purple"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<View className="opacity-70 text-center w-full flex items-center">
|
||||
<Text className="text-xs">
|
||||
{usingOptimizedServer
|
||||
? "Using optimized server"
|
||||
: "Using default method"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const DownloadSingleItem: React.FC<{
|
||||
size?: "default" | "large";
|
||||
item: BaseItemDto;
|
||||
}> = ({ item, size = "default" }) => {
|
||||
return (
|
||||
<DownloadItems
|
||||
size={size}
|
||||
title="Download Episode"
|
||||
subtitle={item.Name!}
|
||||
items={[item]}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,43 @@
|
||||
// GenreTags.tsx
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import {View, ViewProps} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface GenreTagsProps {
|
||||
genres?: string[];
|
||||
interface TagProps {
|
||||
tags?: string[];
|
||||
textClass?: ViewProps["className"]
|
||||
}
|
||||
|
||||
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
||||
if (!genres || genres.length === 0) return null;
|
||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
||||
text,
|
||||
textClass,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||
<Text className={textClass}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-row flex-wrap mt-2">
|
||||
{genres.map((genre, idx) => (
|
||||
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
|
||||
<Text className="text-xs">{genre}</Text>
|
||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||
{tags.map((tag, idx) => (
|
||||
<View>
|
||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<Tags tags={genres}/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
@@ -12,29 +11,24 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -47,10 +41,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
useImageColors({ item });
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
@@ -68,7 +60,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
@@ -87,10 +78,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
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">
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
)}
|
||||
@@ -100,11 +89,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
}, [item.Type]);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
@@ -220,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
className="mr-1"
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
console.log(val);
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import React from "react";
|
||||
import { ItemActions } from "./series/SeriesActions";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -27,7 +28,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<>
|
||||
<EpisodeTitleHeader item={item} />
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
<View className="flex flex-col overflow-visible">
|
||||
<Text className="font-bold ">{title}</Text>
|
||||
{subTitle && (
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
<Text uiTextView selectable className="text-xs text-neutral-400">
|
||||
{subTitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Modal, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
@@ -21,6 +20,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
@@ -30,48 +31,80 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<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}>{selectedName}</Text>
|
||||
<>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<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 justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Media Sources
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{item.MediaSources?.map((source, idx: number) => (
|
||||
<TouchableOpacity
|
||||
key={idx.toString()}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onChange(source);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||
source.Size
|
||||
)}`}
|
||||
</Text>
|
||||
{source.Id === selected?.Id && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||
{item.MediaSources?.map((source, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
onChange(source);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||
source.Size
|
||||
)}`}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
@@ -31,8 +21,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
@@ -48,8 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -79,8 +65,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
@@ -91,137 +75,16 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
if (!client) {
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
return;
|
||||
}
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
const isOpeningCurrentlyPlayingMedia =
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
await CastContext.getPlayServicesState().then(async (state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
// Get a new URL with the Chromecast device profile:
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: chromecastProfile,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
"Client error",
|
||||
"Could not create stream for Chromecast"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}, [
|
||||
item,
|
||||
client,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
router,
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
selectedOptions,
|
||||
]);
|
||||
|
||||
@@ -355,21 +218,13 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
<CastButton tintColor="transparent" />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native";
|
||||
import { Badge } from "./Badge";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -17,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
)}
|
||||
{item.CommunityRating && (
|
||||
<Badge
|
||||
text={item.CommunityRating}
|
||||
text={item.CommunityRating.toFixed(1)}
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
||||
/>
|
||||
@@ -28,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
source={require("@/assets/images/rotten-tomatoes.png")}
|
||||
source={
|
||||
item.CriticRating < 60
|
||||
? require("@/assets/images/rotten-tomatoes.png")
|
||||
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
@@ -40,3 +48,86 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
|
||||
result,
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieRatings(result.id)
|
||||
: jellyseerrApi?.tvRatings(result.id);
|
||||
},
|
||||
staleTime: (5).minutesToMilliseconds(),
|
||||
retry: false,
|
||||
enabled: !!jellyseerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
(isLoading ||
|
||||
!!result.voteCount ||
|
||||
(data?.criticsRating && !!data?.criticsScore) ||
|
||||
(data?.audienceRating && !!data?.audienceScore)) && (
|
||||
<View className="flex flex-row flex-wrap space-x-1">
|
||||
{data?.criticsRating && !!data?.criticsScore && (
|
||||
<Badge
|
||||
text={`${data.criticsScore}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={
|
||||
data?.criticsRating === "Rotten"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{data?.audienceRating && !!data?.audienceScore && (
|
||||
<Badge
|
||||
text={`${data.audienceScore}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={
|
||||
data?.audienceRating === "Spilled"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!result.voteCount && (
|
||||
<Badge
|
||||
text={`${Math.round(result.voteAverage * 10)}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
onPress: () => void;
|
||||
onPress?: () => void;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
background?: boolean;
|
||||
size?: "default" | "large";
|
||||
@@ -32,9 +31,8 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const handlePress = () => {
|
||||
if (hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
onPress();
|
||||
onPress?.();
|
||||
};
|
||||
|
||||
if (fillColor)
|
||||
@@ -98,7 +96,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<BlurView
|
||||
intensity={90}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View, Modal } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -20,6 +20,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
isTranscoding,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||
|
||||
@@ -38,59 +40,98 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex col shrink justify-start place-self-start items-start"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col " {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</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 className=" ">
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: "None"}
|
||||
<>
|
||||
<View
|
||||
className="flex col shrink justify-start place-self-start items-start"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</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"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text>
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: "None"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Subtitle Tracks
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onChange(-1);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>None</Text>
|
||||
{selected === -1 && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<TouchableOpacity
|
||||
key={idx.toString()}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
if (
|
||||
subtitle.Index !== undefined &&
|
||||
subtitle.Index !== null
|
||||
) {
|
||||
onChange(subtitle.Index);
|
||||
setIsModalVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>{subtitle.DisplayTitle}</Text>
|
||||
{subtitle.Index === selected && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"-1"}
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
||||
onChange(subtitle.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{subtitle.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
76
components/common/JellyseerrItemRouter.tsx
Normal file
76
components/common/JellyseerrItemRouter.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
result: MovieResult | TvResult;
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: boolean;
|
||||
posterSrc: string;
|
||||
}
|
||||
|
||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const autoApprove = useMemo(() => {
|
||||
return (
|
||||
jellyseerrUser &&
|
||||
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
||||
type: "or",
|
||||
})
|
||||
);
|
||||
}, [jellyseerrApi, jellyseerrUser]);
|
||||
|
||||
const request = useCallback(
|
||||
() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: result.id,
|
||||
mediaType: result.mediaType,
|
||||
}),
|
||||
[jellyseerrApi, result]
|
||||
);
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// @ts-ignore
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
@@ -68,78 +67,15 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
avoidCollisions
|
||||
alignOffset={0}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(true);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-1-title">
|
||||
Mark as watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
||||
pointSize: 18,
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "green", // Changed to green for "watched"
|
||||
light: "green",
|
||||
},
|
||||
}}
|
||||
androidIconName="checkmark-circle"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
key="item-2"
|
||||
onSelect={() => {
|
||||
markAsPlayedStatus(false);
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
destructive
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-2-title">
|
||||
Mark as not watched
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
ios={{
|
||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
||||
pointSize: 18, // Adjusted for better visibility
|
||||
weight: "semibold",
|
||||
scale: "medium",
|
||||
hierarchicalColor: {
|
||||
dark: "red", // Changed to red for "not watched"
|
||||
light: "red",
|
||||
},
|
||||
// Removed paletteColors as it's not necessary in this case
|
||||
}}
|
||||
androidIconName="eye-slash"
|
||||
></ContextMenu.ItemIcon>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
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 } = 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.item.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(Number(id));
|
||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Download canceled");
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(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, "tick");
|
||||
};
|
||||
|
||||
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,47 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { TextProps } from "react-native";
|
||||
|
||||
interface DownloadSizeProps extends TextProps {
|
||||
items: BaseItemDto[];
|
||||
}
|
||||
|
||||
export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
items,
|
||||
...props
|
||||
}) => {
|
||||
const { downloadedFiles, getDownloadedItemSize } = useDownload();
|
||||
const [size, setSize] = useState<string | undefined>();
|
||||
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadedFiles) return;
|
||||
|
||||
let s = 0;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.Id) continue;
|
||||
const size = getDownloadedItemSize(item.Id);
|
||||
if (size) {
|
||||
s += size;
|
||||
}
|
||||
}
|
||||
setSize(bytesToReadable(s));
|
||||
}, [itemIds]);
|
||||
|
||||
const sizeText = useMemo(() => {
|
||||
if (!size) return "...";
|
||||
return size;
|
||||
}, [size]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="text-xs text-neutral-500" {...props}>
|
||||
{sizeText}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
|
||||
import { useDownloadedFileOpener } 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 { Text } from "@/components/common/Text";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
|
||||
interface EpisodeCardProps extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, [item]);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
key={item.Id}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
<View className="flex flex-row items-start mb-2">
|
||||
<View className="mr-2">
|
||||
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
||||
</View>
|
||||
<View className="shrink">
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||
</Text>
|
||||
<DownloadSize items={[item]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap the parent component with ActionSheetProvider
|
||||
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
|
||||
props
|
||||
) => (
|
||||
<ActionSheetProvider>
|
||||
<EpisodeCard {...props} />
|
||||
</ActionSheetProvider>
|
||||
);
|
||||
@@ -1,113 +0,0 @@
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface MovieCardProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
)}
|
||||
<View className="w-28">
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
<DownloadSize items={[item]} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap the parent component with ActionSheetProvider
|
||||
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
|
||||
<ActionSheetProvider>
|
||||
<MovieCard {...props} />
|
||||
</ActionSheetProvider>
|
||||
);
|
||||
@@ -1,82 +0,0 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {TouchableOpacity, View} from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import React, {useCallback, useMemo} from "react";
|
||||
import {storage} from "@/utils/mmkv";
|
||||
import {Image} from "expo-image";
|
||||
import {Ionicons} from "@expo/vector-icons";
|
||||
import {router} from "expo-router";
|
||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
||||
import {useDownload} from "@/providers/DownloadProvider";
|
||||
import {useActionSheet} from "@expo/react-native-action-sheet";
|
||||
|
||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||
const { deleteItems } = useDownload();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () => deleteItems(items),
|
||||
[items]
|
||||
);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
const options = ["Delete", "Cancel"];
|
||||
const destructiveButtonIndex = 0;
|
||||
|
||||
showActionSheetWithOptions({
|
||||
options,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex == destructiveButtonIndex) {
|
||||
deleteSeries();
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [showActionSheetWithOptions, deleteSeries]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||
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
|
||||
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
|
||||
<Text className="text-xs font-bold">{items.length}</Text>
|
||||
</View>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<View className="w-28 mt-2 flex flex-col">
|
||||
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
||||
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
||||
<DownloadSize items={items} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ 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 {}
|
||||
|
||||
@@ -147,7 +146,6 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
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]);
|
||||
|
||||
72
components/icons/JellyseerrIconStatus.tsx
Normal file
72
components/icons/JellyseerrIconStatus.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
interface Props {
|
||||
mediaStatus?: MediaStatus;
|
||||
showRequestIcon: boolean;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
||||
mediaStatus,
|
||||
showRequestIcon,
|
||||
onPress,
|
||||
...props
|
||||
}) => {
|
||||
const [badgeIcon, setBadgeIcon] = useState<keyof typeof MaterialCommunityIcons.glyphMap>();
|
||||
const [badgeStyle, setBadgeStyle] = useState<string>();
|
||||
|
||||
// Match similar to what Jellyseerr is currently using
|
||||
// https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
|
||||
useEffect(() => {
|
||||
switch (mediaStatus) {
|
||||
case MediaStatus.PROCESSING:
|
||||
setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
|
||||
setBadgeIcon('clock');
|
||||
break;
|
||||
case MediaStatus.AVAILABLE:
|
||||
setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
|
||||
setBadgeIcon('check')
|
||||
break;
|
||||
case MediaStatus.PENDING:
|
||||
setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
|
||||
setBadgeIcon('bell')
|
||||
break;
|
||||
case MediaStatus.BLACKLISTED:
|
||||
setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
|
||||
setBadgeIcon('eye-off')
|
||||
break;
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
|
||||
setBadgeIcon("minus");
|
||||
break;
|
||||
default:
|
||||
if (showRequestIcon) {
|
||||
setBadgeStyle('bg-green-600');
|
||||
setBadgeIcon("plus")
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
|
||||
|
||||
return (
|
||||
badgeIcon &&
|
||||
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
|
||||
<View
|
||||
className={`${badgeStyle ?? 'bg-purple-600'} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
|
||||
{...props}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={badgeIcon}
|
||||
size={18}
|
||||
color="white"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
export default JellyseerrIconStatus;
|
||||
100
components/jellyseerr/DiscoverSlide.tsx
Normal file
100
components/jellyseerr/DiscoverSlide.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useMemo } from "react";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
DiscoverEndpoint,
|
||||
Endpoints,
|
||||
useJellyseerr,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface Props {
|
||||
slide: DiscoverSlider;
|
||||
}
|
||||
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "discover", slide.id],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
let endpoint: DiscoverEndpoint | undefined = undefined;
|
||||
let params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
|
||||
switch (slide.type) {
|
||||
case DiscoverSliderType.TRENDING:
|
||||
endpoint = Endpoints.DISCOVER_TRENDING;
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
endpoint = Endpoints.DISCOVER_MOVIES;
|
||||
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
|
||||
params = {
|
||||
...params,
|
||||
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
|
||||
};
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
endpoint = Endpoints.DISCOVER_TV;
|
||||
if (slide.type === DiscoverSliderType.UPCOMING_TV)
|
||||
params = {
|
||||
...params,
|
||||
firstAirDateGte: new Date().toISOString().split("T")[0],
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
1,
|
||||
enabled: !!jellyseerrApi,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() =>
|
||||
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
flatData &&
|
||||
flatData?.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<Text className="font-bold text-lg mb-2">
|
||||
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||
</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(item) => item!!.id.toString()}
|
||||
estimatedItemSize={250}
|
||||
data={flatData}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) fetchNextPage();
|
||||
}}
|
||||
renderItem={({ item }) =>
|
||||
item ? (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverSlide;
|
||||
@@ -8,11 +8,6 @@ import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
collectionId: string;
|
||||
@@ -32,40 +27,14 @@ 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 { setPlaySettings } = usePlaySettings();
|
||||
|
||||
const openSelect = () => {
|
||||
if (!castDevice?.deviceId) {
|
||||
play("device");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
(selectedIndex: number | undefined) => {
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
play("cast");
|
||||
break;
|
||||
case 1:
|
||||
play("device");
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
play("device");
|
||||
return;
|
||||
};
|
||||
|
||||
const play = useCallback(async (type: "device" | "cast") => {
|
||||
|
||||
92
components/posters/JellyseerrPoster.tsx
Normal file
92
components/posters/JellyseerrPoster.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {View, ViewProps} from "react-native";
|
||||
import {Image} from "expo-image";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||
interface Props extends ViewProps {
|
||||
item: MovieResult | TvResult;
|
||||
}
|
||||
|
||||
const JellyseerrPoster: React.FC<Props> = ({
|
||||
item,
|
||||
...props
|
||||
}) => {
|
||||
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
|
||||
// const imageSource =
|
||||
|
||||
const imageSrc = useMemo(() =>
|
||||
item.posterPath ?
|
||||
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
|
||||
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
|
||||
[item, jellyseerrApi]
|
||||
)
|
||||
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
|
||||
const releaseYear = useMemo(() =>
|
||||
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
|
||||
[item]
|
||||
)
|
||||
|
||||
const showRequestButton = useMemo(() =>
|
||||
jellyseerrUser && hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
item.mediaType === 'movie'
|
||||
? Permission.REQUEST_MOVIE
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
jellyseerrUser.permissions,
|
||||
{type: 'or'}
|
||||
),
|
||||
[item, jellyseerrUser]
|
||||
)
|
||||
|
||||
const canRequest = useMemo(() => {
|
||||
const status = item?.mediaInfo?.status
|
||||
return showRequestButton && !status || status === MediaStatus.UNKNOWN
|
||||
}, [item])
|
||||
|
||||
return (
|
||||
<TouchableJellyseerrRouter
|
||||
result={item}
|
||||
mediaTitle={title}
|
||||
releaseYear={releaseYear}
|
||||
canRequest={canRequest}
|
||||
posterSrc={imageSrc}
|
||||
>
|
||||
<View className="flex flex-col w-28 mr-2">
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||
<Image
|
||||
key={item.id}
|
||||
id={item.id.toString()}
|
||||
source={{uri: imageSrc}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<JellyseerrIconStatus
|
||||
className="absolute bottom-1 right-1"
|
||||
showRequestIcon={canRequest}
|
||||
mediaStatus={item?.mediaInfo?.status}
|
||||
/>
|
||||
|
||||
</View>
|
||||
<View className="mt-2 flex flex-col">
|
||||
<Text numberOfLines={2}>{title}</Text>
|
||||
<Text className="text-xs opacity-50">{releaseYear}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableJellyseerrRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default JellyseerrPoster;
|
||||
275
components/series/JellyseerrSeasons.tsx
Normal file
275
components/series/JellyseerrSeasons.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { orderBy } from "lodash";
|
||||
import { Tags } from "@/components/GenreTags";
|
||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Image } from "expo-image";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
const JellyseerrSeasonEpisodes: React.FC<{
|
||||
details: TvDetails;
|
||||
seasonNumber: number;
|
||||
}> = ({ details, seasonNumber }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
|
||||
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
|
||||
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<HorizontalScroll
|
||||
horizontal
|
||||
loading={isLoading}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
estimatedItemSize={50}
|
||||
data={seasonWithEpisodes?.episodes}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={(item, index) => (
|
||||
<RenderItem key={index} item={item} index={index} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderItem = ({ item, index }: any) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col w-44 mt-2">
|
||||
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
{!imageError ? (
|
||||
<Image
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
source={{
|
||||
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="shrink mt-1">
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${item.seasonNumber}:E${item.episodeNumber}`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||
{item.overview}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const JellyseerrSeasons: React.FC<{
|
||||
isLoading: boolean;
|
||||
result?: TvResult;
|
||||
details?: TvDetails;
|
||||
}> = ({ isLoading, result, details }) => {
|
||||
if (!details) return null;
|
||||
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const [seasonStates, setSeasonStates] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>();
|
||||
const seasons = useMemo(() => {
|
||||
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
|
||||
(s: Season) => s.seasonNumber !== 0
|
||||
);
|
||||
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
|
||||
(r: MediaRequest) => r.seasons
|
||||
);
|
||||
return details.seasons?.map((season) => {
|
||||
return {
|
||||
...season,
|
||||
status:
|
||||
// What our library status is
|
||||
mediaInfoSeasons?.find(
|
||||
(mediaSeason: Season) =>
|
||||
mediaSeason.seasonNumber === season.seasonNumber
|
||||
)?.status ??
|
||||
// What our request status is
|
||||
requestedSeasons?.find(
|
||||
(s: Season) => s.seasonNumber === season.seasonNumber
|
||||
)?.status ??
|
||||
// Otherwise set it as unknown
|
||||
MediaStatus.UNKNOWN,
|
||||
};
|
||||
});
|
||||
}, [details]);
|
||||
|
||||
const allSeasonsAvailable = useMemo(
|
||||
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||
[seasons]
|
||||
);
|
||||
|
||||
const requestAll = useCallback(() => {
|
||||
if (details && jellyseerrApi) {
|
||||
requestMedia(result?.name!!, {
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: seasons
|
||||
.filter(
|
||||
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
|
||||
)
|
||||
.map((s) => s.seasonNumber),
|
||||
});
|
||||
}
|
||||
}, [jellyseerrApi, seasons, details]);
|
||||
|
||||
const promptRequestAll = useCallback(
|
||||
() =>
|
||||
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Yes",
|
||||
onPress: requestAll,
|
||||
},
|
||||
]),
|
||||
[requestAll]
|
||||
);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row justify-between items-end px-4">
|
||||
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||
{!allSeasonsAvailable && (
|
||||
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||
<Ionicons name="bag-add" color="white" size={26} />
|
||||
</RoundButton>
|
||||
)}
|
||||
</View>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={orderBy(
|
||||
details.seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"desc"
|
||||
)}
|
||||
ListHeaderComponent={() => (
|
||||
<View className="flex flex-row justify-between items-end px-4">
|
||||
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||
{!allSeasonsAvailable && (
|
||||
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||
<Ionicons name="bag-add" color="white" size={26} />
|
||||
</RoundButton>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||
estimatedItemSize={250}
|
||||
renderItem={({ item: season }) => (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setSeasonStates((prevState) => ({
|
||||
...prevState,
|
||||
[season.seasonNumber]: !prevState?.[season.seasonNumber],
|
||||
}))
|
||||
}
|
||||
className="px-4"
|
||||
>
|
||||
<View
|
||||
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
|
||||
key={season.id}
|
||||
>
|
||||
<Tags
|
||||
textClass=""
|
||||
tags={[
|
||||
`Season ${season.seasonNumber}`,
|
||||
`${season.episodeCount} Episodes`,
|
||||
]}
|
||||
/>
|
||||
{[0].map(() => {
|
||||
const canRequest =
|
||||
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||
?.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<JellyseerrIconStatus
|
||||
key={0}
|
||||
onPress={
|
||||
canRequest
|
||||
? () =>
|
||||
requestMedia(
|
||||
`${result?.name!!}, Season ${
|
||||
season.seasonNumber
|
||||
}`,
|
||||
{
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: [season.seasonNumber],
|
||||
}
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||
mediaStatus={
|
||||
seasons?.find(
|
||||
(s) => s.seasonNumber === season.seasonNumber
|
||||
)?.status
|
||||
}
|
||||
showRequestIcon={canRequest}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{seasonStates?.[season.seasonNumber] && (
|
||||
<JellyseerrSeasonEpisodes
|
||||
key={season.seasonNumber}
|
||||
details={details}
|
||||
seasonNumber={season.seasonNumber}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyseerrSeasons;
|
||||
@@ -11,6 +11,7 @@ import { Text } from "../common/Text";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
|
||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -43,10 +44,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
||||
<HorizontalScroll
|
||||
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
|
||||
<FlashList
|
||||
contentContainerStyle={{ paddingLeft: 16 }}
|
||||
horizontal
|
||||
estimatedItemSize={172}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={items}
|
||||
renderItem={(item, index) => (
|
||||
renderItem={({ item, index }) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={index}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacity, View, Modal } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -19,7 +19,7 @@ type SeasonKeys = {
|
||||
};
|
||||
|
||||
export type SeasonIndexState = {
|
||||
[seriesId: string]: number | null | undefined;
|
||||
[seriesId: string]: number | string | null | undefined;
|
||||
};
|
||||
|
||||
export const SeasonDropdown: React.FC<Props> = ({
|
||||
@@ -29,6 +29,8 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
state,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const keys = useMemo<SeasonKeys>(
|
||||
() =>
|
||||
item.Type === "Episode"
|
||||
@@ -55,7 +57,6 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
let initialIndex: number | undefined;
|
||||
|
||||
if (initialSeasonIndex !== undefined) {
|
||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
||||
const seasonExists = seasons.some(
|
||||
(season: any) => season[keys.index] === initialSeasonIndex
|
||||
);
|
||||
@@ -65,7 +66,6 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
if (initialIndex === undefined) {
|
||||
// Fall back to the previous logic if initialIndex is not set
|
||||
const season1 = seasons.find((season: any) => season[keys.index] === 1);
|
||||
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
||||
const firstSeason = season1 || season0 || seasons[0];
|
||||
@@ -87,35 +87,65 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>Season {seasonIndex}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
||||
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||
<DropdownMenu.Item
|
||||
key={season[keys.title]}
|
||||
onSelect={() => onSelect(season)}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{season[keys.title]}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Text>Season {seasonIndex}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Season
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||
<TouchableOpacity
|
||||
key={season[keys.title]}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onSelect(season);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{season[keys.title]}</Text>
|
||||
{Number(season[keys.index]) === Number(seasonIndex) && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "../common/Text";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -30,7 +28,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||
const seasonIndex = useMemo(
|
||||
() => seasonIndexState[item.Id ?? ""],
|
||||
[item, seasonIndexState]
|
||||
);
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
@@ -53,19 +54,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
|
||||
return response.data.Items;
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex]
|
||||
);
|
||||
const selectedSeasonId: string | null = useMemo(() => {
|
||||
const season: BaseItemDto = seasons?.find(
|
||||
(s: BaseItemDto) =>
|
||||
s.IndexNumber === seasonIndex || s.Name === seasonIndex
|
||||
);
|
||||
|
||||
if (!season?.Id) return null;
|
||||
|
||||
return season.Id!;
|
||||
}, [seasons, seasonIndex]);
|
||||
|
||||
const { data: episodes, isFetching } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.Id,
|
||||
userId: user.Id,
|
||||
@@ -74,6 +84,12 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
|
||||
if (res.data.TotalRecordCount === 0)
|
||||
console.warn(
|
||||
"No episodes found for season with ID ~",
|
||||
selectedSeasonId
|
||||
);
|
||||
|
||||
return res.data.Items;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
@@ -118,25 +134,15 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
if (!item.Id) return;
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
[item.Id!]: season.IndexNumber ?? season.Name,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<DownloadItems
|
||||
title="Download Season"
|
||||
className="ml-2"
|
||||
items={episodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={20} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons name="download" size={20} color="#9333ea" />
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View className="px-4 flex flex-col my-4">
|
||||
<View className="px-4 flex flex-col mt-4">
|
||||
{isFetching ? (
|
||||
<View
|
||||
style={{
|
||||
@@ -172,9 +178,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto -mt-0.5">
|
||||
<DownloadSingleItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
@@ -186,6 +189,13 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
</TouchableItemRouter>
|
||||
))
|
||||
)}
|
||||
{(episodes?.length || 0) === 0 ? (
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-neutral-500">
|
||||
No episodes for this season
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
32
components/series/SeriesActions.tsx
Normal file
32
components/series/SeriesActions.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const ItemActions = ({ item, ...props }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
|
||||
|
||||
const openTrailer = useCallback(async () => {
|
||||
if (!trailerLink) return;
|
||||
|
||||
const encodedTrailerLink = encodeURIComponent(trailerLink);
|
||||
router.push(`/trailer/page?url=${encodedTrailerLink}`);
|
||||
}, [router, trailerLink]);
|
||||
|
||||
return (
|
||||
<View className="" {...props}>
|
||||
{trailerLink && (
|
||||
<TouchableOpacity onPress={openTrailer}>
|
||||
<Ionicons name="film-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
64
components/series/SeriesHeader.tsx
Normal file
64
components/series/SeriesHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { Ratings } from "../Ratings";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { ItemActions } from "./SeriesActions";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const SeriesHeader = ({ item }: Props) => {
|
||||
const startYear = useMemo(() => {
|
||||
if (item?.StartDate) {
|
||||
return new Date(item.StartDate)
|
||||
.toLocaleDateString("sv-SE", {
|
||||
calendar: "gregory",
|
||||
year: "numeric",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
return item.ProductionYear?.toString().trim();
|
||||
}, [item]);
|
||||
|
||||
const endYear = useMemo(() => {
|
||||
if (item.EndDate) {
|
||||
return new Date(item.EndDate)
|
||||
.toLocaleDateString("sv-SE", {
|
||||
calendar: "gregory",
|
||||
year: "numeric",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}, [item]);
|
||||
|
||||
const yearString = useMemo(() => {
|
||||
if (startYear && endYear) {
|
||||
if (startYear === endYear) return startYear;
|
||||
return `${startYear} - ${endYear}`;
|
||||
}
|
||||
if (startYear) {
|
||||
return startYear;
|
||||
}
|
||||
if (endYear) {
|
||||
return endYear;
|
||||
}
|
||||
return "";
|
||||
}, [startYear, endYear]);
|
||||
|
||||
return (
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{yearString}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { TouchableOpacity, View, ViewProps, Modal } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMedia } from "./MediaContext";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -10,69 +11,35 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const cultures = media.cultures;
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Audio language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default audio language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-audio"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{cultures?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{l.DisplayName}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text>{settings?.defaultAudioLanguage?.DisplayName || "None"}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
@@ -89,6 +56,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
@@ -109,6 +77,71 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Language
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>None</Text>
|
||||
{!settings?.defaultAudioLanguage && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{cultures?.map((l) => (
|
||||
<TouchableOpacity
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{l.DisplayName}</Text>
|
||||
{settings?.defaultAudioLanguage
|
||||
?.ThreeLetterISOLanguageName ===
|
||||
l.ThreeLetterISOLanguageName && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
207
components/settings/Jellyseerr.tsx
Normal file
207
components/settings/Jellyseerr.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Input } from "../common/Input";
|
||||
import { ListItem } from "../ListItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Button } from "../Button";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { toast } from "sonner-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export const JellyseerrSettings = () => {
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
} = useJellyseerr();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
|
||||
string | undefined
|
||||
>(settings?.jellyseerrServerUrl || undefined);
|
||||
|
||||
const loginToJellyseerrMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
|
||||
throw new Error("Missing required information for login");
|
||||
}
|
||||
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to login");
|
||||
},
|
||||
onSettled: () => {
|
||||
setJellyseerrPassword(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const testJellyseerrServerUrlMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl || jellyseerrApi) return null;
|
||||
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||
return jellyseerrTempApi.test();
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result && result.isValid) {
|
||||
if (result.requiresPass) {
|
||||
setPromptForJellyseerrPass(true);
|
||||
} else {
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
}
|
||||
} else {
|
||||
setPromptForJellyseerrPass(false);
|
||||
setjellyseerrServerUrl(undefined);
|
||||
clearAllJellyseerData();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const clearData = () => {
|
||||
clearAllJellyseerData().finally(() => {
|
||||
setjellyseerrServerUrl(undefined);
|
||||
setPromptForJellyseerrPass(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mt-4">
|
||||
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
|
||||
<View>
|
||||
{jellyseerrUser ? (
|
||||
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
|
||||
<ListItem
|
||||
title="Total media requests"
|
||||
subTitle={jellyseerrUser?.requestCount?.toString()}
|
||||
/>
|
||||
<ListItem
|
||||
title="Movie quota limit"
|
||||
subTitle={
|
||||
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Movie quota days"
|
||||
subTitle={
|
||||
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="TV quota limit"
|
||||
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
||||
/>
|
||||
<ListItem
|
||||
title="TV quota days"
|
||||
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
||||
/>
|
||||
<View className="p-4">
|
||||
<Button color="red" onPress={clearData}>
|
||||
Reset Jellyseerr config
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||
<Text className="text-xs text-red-600 mb-2">
|
||||
This integration is in its early stages. Expect things to change.
|
||||
</Text>
|
||||
<Text className="font-bold mb-1">Server URL</Text>
|
||||
<View className="flex flex-col shrink mb-2">
|
||||
<Text className="text-xs text-gray-600">
|
||||
Example: http(s)://your-host.url
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-600">
|
||||
(add port if required)
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
placeholder="Jellyseerr URL..."
|
||||
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!testJellyseerrServerUrlMutation.isPending}
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={testJellyseerrServerUrlMutation.isPending}
|
||||
disabled={testJellyseerrServerUrlMutation.isPending}
|
||||
color={promptForJellyseerrPass ? "red" : "purple"}
|
||||
className="h-12 mt-2"
|
||||
onPress={() => {
|
||||
if (promptForJellyseerrPass) {
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
|
||||
testJellyseerrServerUrlMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{promptForJellyseerrPass ? "Clear" : "Save"}
|
||||
</Button>
|
||||
|
||||
<View
|
||||
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
|
||||
style={{
|
||||
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold mb-2">Password</Text>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
focusable={true}
|
||||
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
|
||||
value={jellyseerrPassword}
|
||||
keyboardType="default"
|
||||
secureTextEntry={true}
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
onChangeText={setJellyseerrPassword}
|
||||
editable={
|
||||
!loginToJellyseerrMutation.isPending &&
|
||||
promptForJellyseerrPass
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
loading={loginToJellyseerrMutation.isPending}
|
||||
disabled={loginToJellyseerrMutation.isPending}
|
||||
color="purple"
|
||||
className="h-12 mt-2"
|
||||
onPress={() => loginToJellyseerrMutation.mutate()}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -57,8 +57,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
updateSettings(update);
|
||||
|
||||
console.log("update", update);
|
||||
|
||||
let updatePayload = {
|
||||
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
|
||||
PlayDefaultAudioTrack:
|
||||
@@ -84,8 +82,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||
"";
|
||||
|
||||
console.log("updatePayload", updatePayload);
|
||||
|
||||
updateUserConfiguration(updatePayload);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,90 +1,38 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
ScreenOrientationEnum,
|
||||
Settings,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
unregisterBackgroundFetchAsync,
|
||||
} from "@/utils/background-tasks";
|
||||
import { getStatistics } from "@/utils/optimize-server";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
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 { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Linking,
|
||||
Modal,
|
||||
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 { MediaToggles } from "./MediaToggles";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { MediaProvider } from "./MediaContext";
|
||||
import { SubtitleToggles } from "./SubtitleToggles";
|
||||
import { AudioToggles } from "./AudioToggles";
|
||||
|
||||
import { JellyseerrSettings } from "./Jellyseerr";
|
||||
import { MediaProvider } from "./MediaContext";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
import { SubtitleToggles } from "./SubtitleToggles";
|
||||
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 downloads 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 [isSearchEngineModalVisible, setIsSearchEngineModalVisible] =
|
||||
useState(false);
|
||||
|
||||
const {
|
||||
data: mediaListCollections,
|
||||
@@ -108,6 +56,13 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
type SearchEngine = "Jellyfin" | "Marlin";
|
||||
|
||||
const searchEngines: Array<{ id: SearchEngine; name: string }> = [
|
||||
{ id: "Jellyfin", name: "Jellyfin" },
|
||||
{ id: "Marlin", name: "Marlin" },
|
||||
];
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
@@ -151,113 +106,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<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">
|
||||
Set the full screen video player orientation.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.DEFAULT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.DEFAULT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="4"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{
|
||||
ScreenOrientationEnum[
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||
]
|
||||
}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Safe area in controls</Text>
|
||||
@@ -344,54 +192,27 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Search engine</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the search engine you want to use.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings.searchEngine}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Marlin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsSearchEngineModalVisible(true)}
|
||||
>
|
||||
<Text>{settings.searchEngine}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{settings.searchEngine === "Marlin" && (
|
||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
@@ -431,208 +252,57 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Show Custom Menu Links</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Show custom menu links defined inside your Jellyfin web
|
||||
config.json file
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text className="text-xs text-purple-600">More info</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.showCustomMenuLinks}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showCustomMenuLinks: value })
|
||||
}
|
||||
/>
|
||||
</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
|
||||
`}
|
||||
<Modal
|
||||
visible={isSearchEngineModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsSearchEngineModalVisible(false)}
|
||||
>
|
||||
<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
|
||||
pointerEvents={
|
||||
settings.downloadMethod === "remux" ? "auto" : "none"
|
||||
}
|
||||
className={`
|
||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||
${
|
||||
settings.downloadMethod === "remux"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Remux max download</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
This is the total media you want to be able to download at the
|
||||
same time.
|
||||
</Text>
|
||||
</View>
|
||||
<Stepper
|
||||
value={settings.remuxConcurrentLimit}
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({
|
||||
remuxConcurrentLimit:
|
||||
value as Settings["remuxConcurrentLimit"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
pointerEvents={
|
||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
||||
}
|
||||
className={`
|
||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
||||
${
|
||||
settings.downloadMethod === "optimized"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<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
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Search Engine
|
||||
</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");
|
||||
}}
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{searchEngines.map((engine) => (
|
||||
<TouchableOpacity
|
||||
key={engine.id}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
searchEngine: engine.id,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
setIsSearchEngineModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{engine.name}</Text>
|
||||
{settings.searchEngine === engine.id && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
<JellyseerrSettings />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useState } from "react";
|
||||
import { Modal, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMedia } from "./MediaContext";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -11,6 +12,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const cultures = media.cultures;
|
||||
const [isLanguageModalVisible, setIsLanguageModalVisible] = useState(false);
|
||||
const [isModeModalVisible, setIsModeModalVisible] = useState(false);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const subtitleModes = [
|
||||
@@ -24,69 +28,31 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-subs"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{cultures?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{l.DisplayName}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsLanguageModalVisible(true)}
|
||||
>
|
||||
<Text>
|
||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle Mode</Text>
|
||||
<Text className="text-xs opacity-50 mr-2">
|
||||
@@ -95,36 +61,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
multiple options are available.
|
||||
</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?.subtitleMode || "Loading"}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
|
||||
{subtitleModes?.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
subtitleMode: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModeModalVisible(true)}
|
||||
>
|
||||
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
@@ -186,6 +134,119 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Modal
|
||||
visible={isLanguageModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsLanguageModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsLanguageModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Language
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: null,
|
||||
});
|
||||
setIsLanguageModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>None</Text>
|
||||
{!settings?.defaultSubtitleLanguage && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{cultures?.map((l) => (
|
||||
<TouchableOpacity
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: l,
|
||||
});
|
||||
setIsLanguageModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{l.DisplayName}</Text>
|
||||
{settings?.defaultSubtitleLanguage
|
||||
?.ThreeLetterISOLanguageName ===
|
||||
l.ThreeLetterISOLanguageName && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsLanguageModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Subtitle Mode Selection Modal */}
|
||||
<Modal
|
||||
visible={isModeModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModeModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModeModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Subtitle Mode
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{subtitleModes?.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
subtitleMode: mode,
|
||||
});
|
||||
setIsModeModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{mode}</Text>
|
||||
{settings?.subtitleMode === mode && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModeModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ type ICommonScreenOptions =
|
||||
navigation: any;
|
||||
}) => NativeStackNavigationOptions);
|
||||
|
||||
const commonScreenOptions: ICommonScreenOptions = {
|
||||
export const commonScreenOptions: ICommonScreenOptions = {
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
|
||||
@@ -20,7 +20,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||
const fetchInitialVolume = async () => {
|
||||
try {
|
||||
const { volume: initialVolume } = await VolumeManager.getVolume();
|
||||
console.log("initialVolume", initialVolume);
|
||||
volume.value = initialVolume * 100;
|
||||
} catch (error) {
|
||||
console.error("Error fetching initial volume:", error);
|
||||
@@ -39,7 +38,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||
|
||||
const handleValueChange = async (value: number) => {
|
||||
volume.value = value;
|
||||
console.log("volume through slider", value);
|
||||
await VolumeManager.setVolume(value / 100);
|
||||
|
||||
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
||||
@@ -48,7 +46,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const volumeListener = VolumeManager.addVolumeListener((result) => {
|
||||
console.log("Volume through device", result.volume);
|
||||
volume.value = result.volume * 100;
|
||||
setVisibility(true);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const BrightnessSlider = () => {
|
||||
useEffect(() => {
|
||||
const fetchInitialBrightness = async () => {
|
||||
const initialBrightness = await Brightness.getBrightnessAsync();
|
||||
console.log("initialBrightness", initialBrightness);
|
||||
brightness.value = initialBrightness * 100;
|
||||
};
|
||||
fetchInitialBrightness();
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -109,7 +108,6 @@ export const Controls: React.FC<Props> = ({
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
stop,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
@@ -124,7 +122,7 @@ export const Controls: React.FC<Props> = ({
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(item, !offline && enableTrickplay);
|
||||
} = useTrickplay(item, enableTrickplay);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Infinity);
|
||||
@@ -142,7 +140,7 @@ export const Controls: React.FC<Props> = ({
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item.Id,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
@@ -150,7 +148,7 @@ export const Controls: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item.Id,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
@@ -160,8 +158,6 @@ export const Controls: React.FC<Props> = ({
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !settings) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
@@ -198,8 +194,6 @@ export const Controls: React.FC<Props> = ({
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
@@ -240,8 +234,6 @@ export const Controls: React.FC<Props> = ({
|
||||
? maxValue - currentProgress
|
||||
: ticksToSeconds(maxValue - currentProgress);
|
||||
|
||||
console.log("remaining: ", remaining);
|
||||
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
},
|
||||
@@ -328,7 +320,7 @@ export const Controls: React.FC<Props> = ({
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
@@ -346,10 +338,9 @@ export const Controls: React.FC<Props> = ({
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings?.forwardSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
try {
|
||||
const curr = progress.value;
|
||||
console.log(curr);
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? curr + secondsToMs(settings.forwardSkipTime)
|
||||
@@ -364,7 +355,6 @@ export const Controls: React.FC<Props> = ({
|
||||
|
||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||
setIgnoreSafeAreas((prev) => !prev);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}, []);
|
||||
|
||||
const memoizedRenderBubble = useCallback(() => {
|
||||
@@ -375,8 +365,6 @@ export const Controls: React.FC<Props> = ({
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
|
||||
console.log("time, ", time);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -445,8 +433,6 @@ export const Controls: React.FC<Props> = ({
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!settings || !gotoItem) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
@@ -519,6 +505,7 @@ export const Controls: React.FC<Props> = ({
|
||||
zIndex: 1000,
|
||||
},
|
||||
]}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
@@ -551,7 +538,7 @@ export const Controls: React.FC<Props> = ({
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||
>
|
||||
{item?.Type === "Episode" && !offline && (
|
||||
{item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
switchOnEpisodeMode();
|
||||
@@ -561,7 +548,7 @@ export const Controls: React.FC<Props> = ({
|
||||
<Ionicons name="list" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{previousItem && !offline && (
|
||||
{previousItem && (
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
@@ -570,7 +557,7 @@ export const Controls: React.FC<Props> = ({
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{nextItem && !offline && (
|
||||
{nextItem && (
|
||||
<TouchableOpacity
|
||||
onPress={goToNextItem}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
@@ -593,7 +580,6 @@ export const Controls: React.FC<Props> = ({
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { View, TouchableOpacity } from "react-native";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
HorizontalScrollRef,
|
||||
} from "@/components/common/HorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -33,7 +31,6 @@ export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets(); // Get safe area insets
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||
const scrollToIndex = (index: number) => {
|
||||
@@ -233,9 +230,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start mt-2">
|
||||
<DownloadSingleItem item={_item} />
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={5}
|
||||
className="text-xs text-neutral-500 shrink"
|
||||
|
||||
@@ -35,7 +35,6 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||
},
|
||||
(finished) => {
|
||||
if (finished && onFinish) {
|
||||
console.log("finish");
|
||||
runOnJS(onFinish)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { View, TouchableOpacity } from "react-native";
|
||||
import { View, TouchableOpacity, Modal } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface DropdownViewDirectProps {
|
||||
showControls: boolean;
|
||||
offline?: boolean; // used to disable external subs for downloads
|
||||
}
|
||||
|
||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
showControls,
|
||||
offline = false,
|
||||
}) => {
|
||||
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
|
||||
const [activeSubMenu, setActiveSubMenu] = useState<
|
||||
"subtitle" | "audio" | null
|
||||
>(null);
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const ControlContext = useControlContext();
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
@@ -53,15 +56,11 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
deliveryUrl: s.DeliveryUrl,
|
||||
})) || [];
|
||||
|
||||
// Combine embedded subs with external subs only if not offline
|
||||
if (!offline) {
|
||||
return [...embeddedSubs, ...externalSubs] as (
|
||||
| EmbeddedSubtitle
|
||||
| ExternalSubtitle
|
||||
)[];
|
||||
}
|
||||
return embeddedSubs as EmbeddedSubtitle[];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
||||
return [...embeddedSubs, ...externalSubs] as (
|
||||
| EmbeddedSubtitle
|
||||
| ExternalSubtitle
|
||||
)[];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||
|
||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
@@ -71,94 +70,143 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||
Subtitle
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`subtitle-item-${idx}`}
|
||||
value={subtitleIndex === sub.index.toString()}
|
||||
onValueChange={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL &&
|
||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
||||
const closeAllModals = () => {
|
||||
setIsMainModalVisible(false);
|
||||
setActiveSubMenu(null);
|
||||
};
|
||||
|
||||
console.log(
|
||||
"Set external subtitle: ",
|
||||
api?.basePath + sub.deliveryUrl
|
||||
);
|
||||
} else {
|
||||
console.log("Set sub index: ", sub.index);
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
}
|
||||
router.setParams({
|
||||
subtitleIndex: sub.index.toString(),
|
||||
});
|
||||
console.log("Subtitle: ", sub);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => {
|
||||
setAudioTrack && setAudioTrack(track.index);
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
const MenuOption = ({
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text>{label}</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
onPress={() => setIsMainModalVisible(true)}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isMainModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={closeAllModals}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={closeAllModals}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
{!activeSubMenu ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Settings
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<MenuOption
|
||||
label="Subtitle"
|
||||
onPress={() => setActiveSubMenu("subtitle")}
|
||||
/>
|
||||
<MenuOption
|
||||
label="Audio"
|
||||
onPress={() => setActiveSubMenu("audio")}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : activeSubMenu === "subtitle" ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">Subtitle</Text>
|
||||
</View>
|
||||
<View className="max-h-[50%]">
|
||||
{allSubtitleTracksForDirectPlay?.map((sub, idx) => (
|
||||
<TouchableOpacity
|
||||
key={`subtitle-${idx}`}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL?.(
|
||||
api?.basePath + sub.deliveryUrl,
|
||||
sub.name
|
||||
);
|
||||
} else {
|
||||
setSubtitleTrack?.(sub.index);
|
||||
}
|
||||
router.setParams({
|
||||
subtitleIndex: sub.index.toString(),
|
||||
});
|
||||
closeAllModals();
|
||||
}}
|
||||
>
|
||||
<Text>{sub.name}</Text>
|
||||
{subtitleIndex === sub.index.toString() && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||
</View>
|
||||
<View className="max-h-[50%]">
|
||||
{audioTracks?.map((track, idx) => (
|
||||
<TouchableOpacity
|
||||
key={`audio-${idx}`}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
setAudioTrack?.(track.index);
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
closeAllModals();
|
||||
}}
|
||||
>
|
||||
<Text>{track.name}</Text>
|
||||
{audioIndex === track.index.toString() && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={closeAllModals}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { View, TouchableOpacity } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Modal, TouchableOpacity, View } from "react-native";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { TranscodedSubtitle } from "../types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
|
||||
interface DropdownViewProps {
|
||||
showControls: boolean;
|
||||
offline?: boolean; // used to disable external subs for downloads
|
||||
}
|
||||
|
||||
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
|
||||
const [activeSubMenu, setActiveSubMenu] = useState<
|
||||
"subtitle" | "audio" | null
|
||||
>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const ControlContext = useControlContext();
|
||||
@@ -66,7 +70,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
|
||||
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
||||
|
||||
console.log("sortedSubtitles", sortedSubtitles);
|
||||
return [disableSubtitle, ...sortedSubtitles];
|
||||
}
|
||||
|
||||
@@ -104,7 +107,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
|
||||
const ChangeTranscodingAudio = useCallback(
|
||||
(audioIndex: number) => {
|
||||
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
@@ -119,6 +121,27 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
[mediaSource, subtitleIndex, audioIndex]
|
||||
);
|
||||
|
||||
const closeAllModals = () => {
|
||||
setIsMainModalVisible(false);
|
||||
setActiveSubMenu(null);
|
||||
};
|
||||
|
||||
const MenuOption = ({
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text>{label}</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -128,110 +151,135 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||
}}
|
||||
className="p-4"
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
<TouchableOpacity
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
onPress={() => setIsMainModalVisible(true)}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isMainModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={closeAllModals}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={closeAllModals}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||
Subtitle
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allSubtitleTracksForTranscodingStream?.map(
|
||||
(sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={
|
||||
subtitleIndex ===
|
||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||
? subtitleHelper
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
{!activeSubMenu ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Settings
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<MenuOption
|
||||
label="Subtitle"
|
||||
onPress={() => setActiveSubMenu("subtitle")}
|
||||
/>
|
||||
<MenuOption
|
||||
label="Audio"
|
||||
onPress={() => setActiveSubMenu("audio")}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : activeSubMenu === "subtitle" ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">Subtitle</Text>
|
||||
</View>
|
||||
<View className="max-h-[50%]">
|
||||
{allSubtitleTracksForTranscodingStream?.map((sub, idx) => (
|
||||
<TouchableOpacity
|
||||
key={`subtitle-${idx}`}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
if (
|
||||
subtitleIndex ===
|
||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||
? subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString()
|
||||
: sub?.index.toString())
|
||||
)
|
||||
return;
|
||||
|
||||
router.setParams({
|
||||
subtitleIndex: subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString()
|
||||
: sub?.index.toString())
|
||||
}
|
||||
key={`subtitle-item-${idx}`}
|
||||
onValueChange={() => {
|
||||
console.log("sub", sub);
|
||||
if (
|
||||
subtitleIndex ===
|
||||
.toString(),
|
||||
});
|
||||
|
||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
} else {
|
||||
changeToImageBasedSub(sub.index);
|
||||
}
|
||||
closeAllModals();
|
||||
}}
|
||||
>
|
||||
<Text>{sub.name}</Text>
|
||||
{subtitleIndex ===
|
||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||
? subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString()
|
||||
: sub?.index.toString())
|
||||
)
|
||||
return;
|
||||
: sub?.index.toString()) && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||
</View>
|
||||
<View className="max-h-[50%]">
|
||||
{allAudio?.map((track, idx) => (
|
||||
<TouchableOpacity
|
||||
key={`audio-${idx}`}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
if (audioIndex === track.index.toString()) return;
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
ChangeTranscodingAudio(track.index);
|
||||
closeAllModals();
|
||||
}}
|
||||
>
|
||||
<Text>{track.name}</Text>
|
||||
{audioIndex === track.index.toString() && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
router.setParams({
|
||||
subtitleIndex: subtitleHelper
|
||||
.getSourceSubtitleIndex(sub.index)
|
||||
.toString(),
|
||||
});
|
||||
|
||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
return;
|
||||
}
|
||||
changeToImageBasedSub(sub.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={closeAllModals}
|
||||
>
|
||||
{allAudio?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => {
|
||||
if (audioIndex === track.index.toString()) return;
|
||||
console.log("Setting audio track to: ", track.index);
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
ChangeTranscodingAudio(track.index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.22.0",
|
||||
"channel": "0.23.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.22.0",
|
||||
"channel": "0.23.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface CreditTimestamps {
|
||||
Introduction: {
|
||||
@@ -79,7 +78,6 @@ export const useCreditSkipper = (
|
||||
if (!creditTimestamps) return;
|
||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||
try {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
wrappedSeek(creditTimestamps.Credits.End);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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 getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
||||
const directory = FileSystem.documentDirectory;
|
||||
|
||||
if (!directory) {
|
||||
throw new Error("Document directory is not available");
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
throw new Error("Item ID is not available");
|
||||
}
|
||||
|
||||
const files = await FileSystem.readDirectoryAsync(directory);
|
||||
const path = itemId!;
|
||||
const matchingFile = files.find((file) => file.startsWith(path));
|
||||
|
||||
if (!matchingFile) {
|
||||
throw new Error(`No file found for item ${path}`);
|
||||
}
|
||||
|
||||
return `${directory}${matchingFile}`;
|
||||
};
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
try {
|
||||
// @ts-expect-error
|
||||
router.push("/player/direct-player?offline=true&itemId=" + item.Id);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
}
|
||||
},
|
||||
[setOfflineSettings, setPlayUrl, router]
|
||||
);
|
||||
|
||||
return { openFile };
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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 (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) {
|
||||
setPrimaryColor({
|
||||
primary: _primary,
|
||||
text: _text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors) => {
|
||||
let primary: 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;
|
||||
backup = colors.vibrant;
|
||||
} else if (colors.platform === "ios") {
|
||||
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,
|
||||
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);
|
||||
});
|
||||
}
|
||||
}, [source?.uri, setPrimaryColor, disabled]);
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
@@ -76,10 +75,8 @@ export const useIntroSkipper = (
|
||||
}, [introTimestamps, currentTime]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
console.log("skipIntro");
|
||||
if (!introTimestamps) return;
|
||||
try {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
wrappedSeek(introTimestamps.IntroEnd);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
|
||||
378
hooks/useJellyseerr.ts
Normal file
378
hooks/useJellyseerr.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||
import { Results } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { inRange } from "lodash";
|
||||
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
import { atom } from "jotai";
|
||||
import { useAtom } from "jotai/index";
|
||||
import "@/augmentations";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { toast } from "sonner-native";
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import {
|
||||
SeasonWithEpisodes,
|
||||
TvDetails,
|
||||
} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import {
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import Issue from "@/utils/jellyseerr/server/entity/Issue";
|
||||
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||
import { writeErrorLog } from "@/utils/log";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
|
||||
interface SearchParams {
|
||||
query: string;
|
||||
page: number;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: Results[];
|
||||
}
|
||||
|
||||
const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||
|
||||
export const clearJellyseerrStorageData = () => {
|
||||
storage.delete(JELLYSEERR_USER);
|
||||
storage.delete(JELLYSEERR_COOKIES);
|
||||
};
|
||||
|
||||
export enum Endpoints {
|
||||
STATUS = "/status",
|
||||
API_V1 = "/api/v1",
|
||||
SEARCH = "/search",
|
||||
REQUEST = "/request",
|
||||
MOVIE = "/movie",
|
||||
RATINGS = "/ratings",
|
||||
ISSUE = "/issue",
|
||||
TV = "/tv",
|
||||
SETTINGS = "/settings",
|
||||
DISCOVER = "/discover",
|
||||
DISCOVER_TRENDING = DISCOVER + "/trending",
|
||||
DISCOVER_MOVIES = DISCOVER + "/movies",
|
||||
DISCOVER_TV = DISCOVER + TV,
|
||||
AUTH_JELLYFIN = "/auth/jellyfin",
|
||||
}
|
||||
|
||||
export type DiscoverEndpoint =
|
||||
| Endpoints.DISCOVER_TRENDING
|
||||
| Endpoints.DISCOVER_MOVIES
|
||||
| Endpoints.DISCOVER_TV;
|
||||
|
||||
export type TestResult =
|
||||
| {
|
||||
isValid: true;
|
||||
requiresPass: boolean;
|
||||
}
|
||||
| {
|
||||
isValid: false;
|
||||
};
|
||||
|
||||
export class JellyseerrApi {
|
||||
axios: AxiosInstance;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
xsrfHeaderName: "XSRF-TOKEN",
|
||||
});
|
||||
|
||||
this.setInterceptors();
|
||||
}
|
||||
|
||||
async test(): Promise<TestResult> {
|
||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
|
||||
if (user && cookies) {
|
||||
return Promise.resolve({
|
||||
isValid: true,
|
||||
requiresPass: false,
|
||||
});
|
||||
}
|
||||
|
||||
return await this.axios
|
||||
.get(Endpoints.API_V1 + Endpoints.STATUS)
|
||||
.then((response) => {
|
||||
const { status, headers, data } = response;
|
||||
if (inRange(status, 200, 299)) {
|
||||
if (data.version < "2.0.0") {
|
||||
const error =
|
||||
"Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0";
|
||||
toast.error(error);
|
||||
throw Error(error);
|
||||
}
|
||||
|
||||
storage.setAny(
|
||||
JELLYSEERR_COOKIES,
|
||||
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? []
|
||||
);
|
||||
return {
|
||||
isValid: true,
|
||||
requiresPass: true,
|
||||
};
|
||||
}
|
||||
toast.error(`Jellyseerr test failed. Please try again.`);
|
||||
writeErrorLog(
|
||||
`Jellyseerr returned a ${status} for url:\n` +
|
||||
response.config.url +
|
||||
"\n" +
|
||||
JSON.stringify(response.data)
|
||||
);
|
||||
return {
|
||||
isValid: false,
|
||||
requiresPass: false,
|
||||
};
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg = "Failed to test jellyseerr server url";
|
||||
toast.error(msg);
|
||||
console.error(msg, e);
|
||||
return {
|
||||
isValid: false,
|
||||
requiresPass: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<JellyseerrUser> {
|
||||
return this.axios
|
||||
?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||
username,
|
||||
password,
|
||||
email: username,
|
||||
})
|
||||
.then((response) => {
|
||||
const user = response?.data;
|
||||
if (!user) throw Error("Login failed");
|
||||
storage.setAny(JELLYSEERR_USER, user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async discoverSettings(): Promise<DiscoverSlider[]> {
|
||||
return this.axios
|
||||
?.get<DiscoverSlider[]>(
|
||||
Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async discover(
|
||||
endpoint: DiscoverEndpoint,
|
||||
params: any
|
||||
): Promise<SearchResults> {
|
||||
return this.axios
|
||||
?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params })
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async search(params: SearchParams): Promise<SearchResults> {
|
||||
const response = await this.axios?.get<SearchResults>(
|
||||
Endpoints.API_V1 + Endpoints.SEARCH,
|
||||
{ params }
|
||||
);
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
async request(request: MediaRequestBody): Promise<MediaRequest> {
|
||||
return this.axios
|
||||
?.post<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST, request)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async movieDetails(id: number) {
|
||||
return this.axios
|
||||
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async movieRatings(id: number) {
|
||||
return this.axios
|
||||
?.get<RTRating>(
|
||||
`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async tvDetails(id: number) {
|
||||
return this.axios
|
||||
?.get<TvDetails>(`${Endpoints.API_V1}${Endpoints.TV}/${id}`)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async tvRatings(id: number) {
|
||||
return this.axios
|
||||
?.get<RTRating>(
|
||||
`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async tvSeason(id: number, seasonId: number) {
|
||||
return this.axios
|
||||
?.get<SeasonWithEpisodes>(
|
||||
`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`
|
||||
)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
|
||||
return (
|
||||
this.axios.defaults.baseURL +
|
||||
`/_next/image?` +
|
||||
new URLSearchParams(
|
||||
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
|
||||
).toString()
|
||||
);
|
||||
}
|
||||
|
||||
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
|
||||
return this.axios
|
||||
?.post<Issue>(Endpoints.API_V1 + Endpoints.ISSUE, {
|
||||
mediaId,
|
||||
issueType,
|
||||
message,
|
||||
})
|
||||
.then((response) => {
|
||||
const issue = response.data;
|
||||
|
||||
if (issue.status === IssueStatus.OPEN) {
|
||||
toast.success("Issue submitted!");
|
||||
}
|
||||
return issue;
|
||||
});
|
||||
}
|
||||
|
||||
private setInterceptors() {
|
||||
this.axios.interceptors.response.use(
|
||||
async (response) => {
|
||||
const cookies = response.headers["set-cookie"];
|
||||
if (cookies) {
|
||||
storage.setAny(
|
||||
JELLYSEERR_COOKIES,
|
||||
response.headers["set-cookie"]?.flatMap((c) => c.split("; "))
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
const errorMsg = "Jellyseerr response error";
|
||||
console.error(errorMsg, error, error.response?.data);
|
||||
writeErrorLog(
|
||||
errorMsg +
|
||||
`\n` +
|
||||
`error: ${error.toString()}\n` +
|
||||
`url: ${error?.config?.url}\n` +
|
||||
`data:\n` +
|
||||
JSON.stringify(error.response?.data)
|
||||
);
|
||||
if (error.status === 403) {
|
||||
clearJellyseerrStorageData();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
this.axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
if (cookies) {
|
||||
const headerName = this.axios.defaults.xsrfHeaderName!!;
|
||||
const xsrfToken = cookies
|
||||
.find((c) => c.includes(headerName))
|
||||
?.split(headerName + "=")?.[1];
|
||||
if (xsrfToken) {
|
||||
config.headers[headerName] = xsrfToken;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error("Jellyseerr request error", error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
||||
|
||||
export const useJellyseerr = () => {
|
||||
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const jellyseerrApi = useMemo(() => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
|
||||
return new JellyseerrApi(settings?.jellyseerrServerUrl);
|
||||
}
|
||||
return undefined;
|
||||
}, [settings?.jellyseerrServerUrl, jellyseerrUser]);
|
||||
|
||||
const clearAllJellyseerData = useCallback(async () => {
|
||||
clearJellyseerrStorageData();
|
||||
setJellyseerrUser(undefined);
|
||||
updateSettings({ jellyseerrServerUrl: undefined });
|
||||
}, []);
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody) => {
|
||||
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
||||
switch (mediaRequest.status) {
|
||||
case MediaRequestStatus.PENDING:
|
||||
case MediaRequestStatus.APPROVED:
|
||||
toast.success(`Requested ${title}!`);
|
||||
break;
|
||||
case MediaRequestStatus.DECLINED:
|
||||
toast.error(`You don't have permission to request!`);
|
||||
break;
|
||||
case MediaRequestStatus.FAILED:
|
||||
toast.error(`Something went wrong requesting media!`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
[jellyseerrApi]
|
||||
);
|
||||
|
||||
const isJellyseerrResult = (
|
||||
items: any[] | null | undefined
|
||||
): items is Results[] => {
|
||||
return (
|
||||
!items ||
|
||||
(items.length >= 0 &&
|
||||
Object.hasOwn(items[0], "mediaType") &&
|
||||
Object.values(MediaType).includes(items[0]["mediaType"]))
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
isJellyseerrResult,
|
||||
requestMedia,
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
||||
@@ -29,8 +28,6 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = async (played: boolean) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData(
|
||||
["item", item.Id],
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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, setOrientation };
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
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,201 +0,0 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import useImageStorage from "./useImageStorage";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
|
||||
const createFFmpegCommand = (url: string, output: string) => [
|
||||
"-y", // overwrite output files without asking
|
||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||
|
||||
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
||||
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
||||
"-multiple_requests 1", // http
|
||||
"-tcp_nodelay 1", // http
|
||||
// endregion ffmpeg protocol commands
|
||||
|
||||
"-fflags +genpts", // format flags
|
||||
`-i ${url}`, // infile
|
||||
"-map 0:v -map 0:a", // select all streams for video & audio
|
||||
"-c copy", // streamcopy, preventing transcoding
|
||||
"-bufsize 25M", // amount of data processed before calculating current bitrate
|
||||
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
|
||||
output,
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
*
|
||||
* @param url - The URL of the HLS stream
|
||||
* @param item - The BaseItemDto object representing the media item
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { saveImage } = useImageStorage();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload();
|
||||
|
||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
};
|
||||
|
||||
const completeCallback = useCallback(
|
||||
async (session: FFmpegSession, item: BaseItemDto) => {
|
||||
try {
|
||||
console.log("completeCallback");
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await FileSystem.moveAsync({
|
||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
saveDownloadedItemInfo(item, stat.getSize());
|
||||
toast.success("Download completed");
|
||||
}
|
||||
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
console.log("completeCallback ~ end");
|
||||
},
|
||||
[processes, setProcesses]
|
||||
);
|
||||
|
||||
const statisticsCallback = useCallback(
|
||||
(statistics: Statistics, item: BaseItemDto) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
||||
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
setProcesses((prev) => {
|
||||
return prev.map((process) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
id: statistics.getSessionId().toString(),
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
});
|
||||
},
|
||||
[setProcesses, completeCallback]
|
||||
);
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
||||
if (!cacheDir.exists) {
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true})
|
||||
}
|
||||
|
||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`
|
||||
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
// First lets save any important assets we want to present to the user offline
|
||||
await onSaveAssets(api, item);
|
||||
|
||||
toast.success(`Download started for ${item.Name}`, {
|
||||
action: {
|
||||
label: "Go to download",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const job: JobStatus = {
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: url,
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
outputPath: output,
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||
setProcesses((prev) => [...prev, job]);
|
||||
|
||||
await FFmpegKit.executeAsync(
|
||||
createFFmpegCommand(url, output).join(" "),
|
||||
(session) => completeCallback(session, item),
|
||||
undefined,
|
||||
(s) => statisticsCallback(s, item)
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error("Failed to remux:", error);
|
||||
writeErrorLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||
Error: ${error.message}, Stack: ${error.stack}`
|
||||
);
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[settings, processes, setProcesses, completeCallback, statisticsCallback]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProcesses([]);
|
||||
}, []);
|
||||
|
||||
return { startRemuxing, cancelRemuxing };
|
||||
};
|
||||
@@ -7,21 +7,18 @@ interface UseWebSocketProps {
|
||||
isPlaying: boolean;
|
||||
togglePlay: () => void;
|
||||
stopPlayback: () => void;
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
export const useWebSocket = ({
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
stopPlayback,
|
||||
offline,
|
||||
}: UseWebSocketProps) => {
|
||||
const router = useRouter();
|
||||
const { ws } = useWebSocketContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
if (offline) return;
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const json = JSON.parse(e.data);
|
||||
|
||||
@@ -10,7 +10,7 @@ Pod::Spec.new do |s|
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MobileVLCKit', '~> 3.6.1b1'
|
||||
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ExpoModulesCore
|
||||
import MobileVLCKit
|
||||
import TVVLCKit
|
||||
import UIKit
|
||||
|
||||
class VlcPlayerView: ExpoView {
|
||||
|
||||
33
package.json
33
package.json
@@ -3,11 +3,12 @@
|
||||
"main": "./index",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"submodule-reload": "git submodule update --init --remote --recursive",
|
||||
"start": "bun run submodule-reload && expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"android": "bun run submodule-reload && expo run:android",
|
||||
"ios": "bun run submodule-reload && expo run:ios",
|
||||
"web": "bun run submodule-reload && expo start --web",
|
||||
"test": "jest --watchAll",
|
||||
"lint": "expo lint",
|
||||
"postinstall": "patch-package"
|
||||
@@ -23,7 +24,6 @@
|
||||
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
||||
"@gorhom/bottom-sheet": "^4.6.4",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@react-native-menu/menu": "^1.1.6",
|
||||
@@ -38,7 +38,6 @@
|
||||
"axios": "^1.7.7",
|
||||
"expo": "~51.0.39",
|
||||
"expo-asset": "~10.0.10",
|
||||
"expo-background-fetch": "~12.0.1",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-brightness": "~12.0.1",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
@@ -46,22 +45,17 @@
|
||||
"expo-dev-client": "~4.0.29",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.10",
|
||||
"expo-haptics": "~13.0.1",
|
||||
"expo-image": "~1.13.0",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-network": "~6.0.1",
|
||||
"expo-notifications": "~0.28.19",
|
||||
"expo-router": "~3.5.24",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.7",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "^3.0.7",
|
||||
"expo-task-manager": "~11.8.2",
|
||||
"expo-updates": "~0.25.27",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"install": "^0.13.0",
|
||||
"jotai": "^2.10.1",
|
||||
@@ -69,7 +63,7 @@
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native": "npm:react-native-tvos@0.74.5-0",
|
||||
"react-native-awesome-slider": "^2.5.6",
|
||||
"react-native-bottom-tabs": "0.7.1",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
@@ -78,9 +72,7 @@
|
||||
"react-native-edge-to-edge": "^1.1.1",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^2.5.2",
|
||||
"react-native-ios-utilities": "^4.5.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.3.0",
|
||||
@@ -97,6 +89,8 @@
|
||||
"react-native-video": "^6.7.0",
|
||||
"react-native-volume-manager": "^1.10.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "^13.12.5",
|
||||
"react-native-youtube-iframe": "^2.3.0",
|
||||
"sonner-native": "^0.14.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
@@ -106,6 +100,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@react-native-community/cli": "^15.1.3",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
@@ -116,5 +112,12 @@
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
const { readFileSync, writeFileSync } = require("fs");
|
||||
const { join } = require("path");
|
||||
const { withDangerousMod } = require("@expo/config-plugins");
|
||||
|
||||
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
||||
withDangerousMod(expoConfig, [
|
||||
"android",
|
||||
(modConfig) => {
|
||||
if (modConfig.modRequest.platform === "android") {
|
||||
const stylesXmlPath = join(
|
||||
modConfig.modRequest.platformProjectRoot,
|
||||
"app",
|
||||
"src",
|
||||
"main",
|
||||
"res",
|
||||
"values",
|
||||
"styles.xml"
|
||||
);
|
||||
|
||||
let stylesXml = readFileSync(stylesXmlPath, "utf8");
|
||||
|
||||
stylesXml = stylesXml.replace(/@android:color\/black/g, "@android:color/white");
|
||||
|
||||
writeFileSync(stylesXmlPath, stylesXml, { encoding: "utf8" });
|
||||
}
|
||||
return modConfig;
|
||||
},
|
||||
]);
|
||||
|
||||
module.exports = withChangeNativeAndroidTextToWhite;
|
||||
@@ -1,48 +0,0 @@
|
||||
const { withAppDelegate } = require("@expo/config-plugins");
|
||||
|
||||
function withRNBackgroundDownloader(expoConfig) {
|
||||
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
|
||||
const { modResults: appDelegate } = appDelegateConfig;
|
||||
const appDelegateLines = appDelegate.contents.split("\n");
|
||||
|
||||
// Define the code to be added to AppDelegate.mm
|
||||
const backgroundDownloaderImport =
|
||||
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
|
||||
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
|
||||
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
|
||||
{
|
||||
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
|
||||
}`;
|
||||
|
||||
// Find the index of the AppDelegate import statement
|
||||
const importIndex = appDelegateLines.findIndex((line) =>
|
||||
/^#import "AppDelegate.h"/.test(line)
|
||||
);
|
||||
|
||||
// Find the index of the last line before the @end statement
|
||||
const endStatementIndex = appDelegateLines.findIndex((line) =>
|
||||
/@end/.test(line)
|
||||
);
|
||||
|
||||
// Insert the import statement if it's not already present
|
||||
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
|
||||
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
|
||||
}
|
||||
|
||||
// Insert the delegate method above the @end statement
|
||||
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
|
||||
appDelegateLines.splice(
|
||||
endStatementIndex,
|
||||
0,
|
||||
backgroundDownloaderDelegate
|
||||
);
|
||||
}
|
||||
|
||||
// Update the contents of the AppDelegate file
|
||||
appDelegate.contents = appDelegateLines.join("\n");
|
||||
|
||||
return appDelegateConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withRNBackgroundDownloader;
|
||||
@@ -1,726 +0,0 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import { useLog, writeToLog } from "@/utils/log";
|
||||
import {
|
||||
cancelAllJobs,
|
||||
cancelJobById,
|
||||
deleteDownloadItemInfoFromDiskTmp,
|
||||
getAllJobsByDeviceId,
|
||||
getDownloadItemInfoFromDiskTmp,
|
||||
JobStatus,
|
||||
} from "@/utils/optimize-server";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
checkForExistingDownloads,
|
||||
completeHandler,
|
||||
download,
|
||||
setConfig,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import MMKV from "react-native-mmkv";
|
||||
import {
|
||||
focusManager,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {AppState, AppStateStatus, Platform} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom } from "./JellyfinProvider";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import { FileInfo } from "expo-file-system";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as Application from "expo-application";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
function onAppStateChange(status: AppStateStatus) {
|
||||
focusManager.setFocused(status === "active");
|
||||
}
|
||||
|
||||
const DownloadContext = createContext<ReturnType<
|
||||
typeof useDownloadProvider
|
||||
> | null>(null);
|
||||
|
||||
function useDownloadProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { logs } = useLog();
|
||||
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const authHeader = useMemo(() => {
|
||||
return api?.accessToken;
|
||||
}, [api]);
|
||||
|
||||
const { data: downloadedFiles, refetch } = useQuery({
|
||||
queryKey: ["downloadedItems"],
|
||||
queryFn: getAllDownloadedItems,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: async () => {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (
|
||||
settings?.downloadMethod !== "optimized" ||
|
||||
!url ||
|
||||
!deviceId ||
|
||||
!authHeader
|
||||
)
|
||||
return [];
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader,
|
||||
url,
|
||||
});
|
||||
|
||||
const downloadingProcesses = processes
|
||||
.filter((p) => p.status === "downloading")
|
||||
.filter((p) => jobs.some((j) => j.id === p.id));
|
||||
|
||||
const updatedProcesses = jobs.filter(
|
||||
(j) => !downloadingProcesses.some((p) => p.id === j.id)
|
||||
);
|
||||
|
||||
setProcesses([...updatedProcesses, ...downloadingProcesses]);
|
||||
|
||||
for (let job of jobs) {
|
||||
const process = processes.find((p) => p.id === job.id);
|
||||
if (
|
||||
process &&
|
||||
process.status === "optimizing" &&
|
||||
job.status === "completed"
|
||||
) {
|
||||
if (settings.autoDownload) {
|
||||
startDownload(job);
|
||||
} else {
|
||||
toast.info(`${job.item.Name} is ready to be downloaded`, {
|
||||
action: {
|
||||
label: "Go to downloads",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: `${job.item.Name} is ready to be downloaded`,
|
||||
data: {
|
||||
url: `/downloads`,
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: 2000,
|
||||
enabled: settings?.downloadMethod === "optimized",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkIfShouldStartDownload = async () => {
|
||||
if (processes.length === 0) return;
|
||||
await checkForExistingDownloads();
|
||||
};
|
||||
|
||||
checkIfShouldStartDownload();
|
||||
}, [settings, processes]);
|
||||
|
||||
const removeProcess = useCallback(
|
||||
async (id: string) => {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
|
||||
return;
|
||||
|
||||
try {
|
||||
await cancelJobById({
|
||||
authHeader,
|
||||
id,
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`
|
||||
|
||||
const startDownload = useCallback(
|
||||
async (process: JobStatus) => {
|
||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === process.id
|
||||
? {
|
||||
...p,
|
||||
speed: undefined,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
|
||||
setConfig({
|
||||
isLogsEnabled: true,
|
||||
progressInterval: 500,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
toast.info(`Download started for ${process.item.Name}`, {
|
||||
action: {
|
||||
label: "Go to downloads",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
download({
|
||||
id: process.id,
|
||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
||||
})
|
||||
.begin(() => {
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === process.id
|
||||
? {
|
||||
...p,
|
||||
speed: undefined,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
})
|
||||
.progress((data) => {
|
||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === process.id
|
||||
? {
|
||||
...p,
|
||||
speed: undefined,
|
||||
status: "downloading",
|
||||
progress: percent,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
})
|
||||
.done(async (doneHandler) => {
|
||||
await saveDownloadedItemInfo(
|
||||
process.item,
|
||||
doneHandler.bytesDownloaded
|
||||
);
|
||||
toast.success(`Download completed for ${process.item.Name}`, {
|
||||
duration: 3000,
|
||||
action: {
|
||||
label: "Go to downloads",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
completeHandler(process.id);
|
||||
removeProcess(process.id);
|
||||
}, 1000);
|
||||
})
|
||||
.error(async (error) => {
|
||||
removeProcess(process.id);
|
||||
completeHandler(process.id);
|
||||
let errorMsg = "";
|
||||
if (error.errorCode === 1000) {
|
||||
errorMsg = "No space left";
|
||||
}
|
||||
if (error.errorCode === 404) {
|
||||
errorMsg = "File not found on server";
|
||||
}
|
||||
toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
|
||||
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
||||
error,
|
||||
processDetails: {
|
||||
id: process.id,
|
||||
itemName: process.item.Name,
|
||||
itemId: process.item.Id,
|
||||
},
|
||||
});
|
||||
console.error("Error details:", {
|
||||
errorCode: error.errorCode,
|
||||
});
|
||||
});
|
||||
},
|
||||
[queryClient, settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
||||
if (!api || !item.Id || !authHeader)
|
||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||
|
||||
try {
|
||||
const fileExtension = mediaSource.TranscodingContainer;
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
|
||||
const response = await axios.post(
|
||||
settings?.optimizedVersionsServerUrl + "optimize-version",
|
||||
{
|
||||
url,
|
||||
fileExtension,
|
||||
deviceId,
|
||||
itemId: item.Id,
|
||||
item,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error("Failed to start optimization job");
|
||||
}
|
||||
|
||||
toast.success(`Queued ${item.Name} for optimization`, {
|
||||
action: {
|
||||
label: "Go to download",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error in startBackgroundDownload", error);
|
||||
console.error("Error in startBackgroundDownload:", error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error("Axios error details:", {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
headers: error.response?.headers,
|
||||
});
|
||||
toast.error(
|
||||
`Failed to start download for ${item.Name}: ${error.message}`
|
||||
);
|
||||
if (error.response) {
|
||||
toast.error(
|
||||
`Server responded with status ${error.response.status}`
|
||||
);
|
||||
} else if (error.request) {
|
||||
toast.error("No response received from server");
|
||||
} else {
|
||||
toast.error("Error setting up the request");
|
||||
}
|
||||
} else {
|
||||
console.error("Non-Axios error:", error);
|
||||
toast.error(
|
||||
`Failed to start download for ${item.Name}: Unexpected error`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
const deleteAllFiles = async (): Promise<void> => {
|
||||
Promise.all([
|
||||
deleteLocalFiles(),
|
||||
removeDownloadedItemsFromStorage(),
|
||||
cancelAllServerJobs(),
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
||||
])
|
||||
.then(() =>
|
||||
toast.success("All files, folders, and jobs deleted successfully")
|
||||
)
|
||||
.catch((reason) => {
|
||||
console.error("Failed to delete all files, folders, and jobs:", reason);
|
||||
toast.error("An error occurred while deleting files and jobs");
|
||||
});
|
||||
};
|
||||
|
||||
const forEveryDocumentDirFile = async (
|
||||
includeMMKV: boolean = true,
|
||||
ignoreList: string[] = [],
|
||||
callback: (file: FileInfo) => void
|
||||
) => {
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
if (!baseDirectory) {
|
||||
throw new Error("Base directory not found");
|
||||
}
|
||||
|
||||
console.log(`ignoreList length: ${ignoreList?.length}`);
|
||||
|
||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
for (const item of dirContents) {
|
||||
// Exclude mmkv directory.
|
||||
// Deleting this deletes all user information as well. Logout should handle this.
|
||||
if (
|
||||
(item == "mmkv" && !includeMMKV) ||
|
||||
ignoreList.some(i => item.includes(i))
|
||||
) {
|
||||
console.log("Skipping read for item", item)
|
||||
continue;
|
||||
}
|
||||
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
|
||||
.then((itemInfo) => {
|
||||
console.log("Loading itemInfo", itemInfo);
|
||||
|
||||
if (itemInfo.exists && !itemInfo.isDirectory) {
|
||||
callback(itemInfo);
|
||||
}
|
||||
})
|
||||
.catch(e =>
|
||||
console.error(e)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteLocalFiles = async (): Promise<void> => {
|
||||
await forEveryDocumentDirFile(false, [], (file) => {
|
||||
console.warn("Deleting file", file.uri);
|
||||
FileSystem.deleteAsync(file.uri, { idempotent: true });
|
||||
});
|
||||
};
|
||||
|
||||
const removeDownloadedItemsFromStorage = async () => {
|
||||
// delete any saved images first
|
||||
Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")])
|
||||
.then(() => storage.delete("downloadedItems"))
|
||||
.catch((reason) => {
|
||||
console.error("Failed to remove downloadedItems from storage:", reason);
|
||||
throw reason;
|
||||
});
|
||||
};
|
||||
|
||||
const cancelAllServerJobs = async (): Promise<void> => {
|
||||
if (!authHeader) {
|
||||
throw new Error("No auth header available");
|
||||
}
|
||||
if (!settings?.optimizedVersionsServerUrl) {
|
||||
console.error("No server URL configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
if (!deviceId) {
|
||||
throw new Error("Failed to get device ID");
|
||||
}
|
||||
|
||||
try {
|
||||
await cancelAllJobs({
|
||||
authHeader,
|
||||
url: settings.optimizedVersionsServerUrl,
|
||||
deviceId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel all server jobs:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (id: string): Promise<void> => {
|
||||
if (!id) {
|
||||
console.error("Invalid file ID");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = FileSystem.documentDirectory;
|
||||
|
||||
if (!directory) {
|
||||
console.error("Document directory not found");
|
||||
return;
|
||||
}
|
||||
const dirContents = await FileSystem.readDirectoryAsync(directory);
|
||||
|
||||
for (const item of dirContents) {
|
||||
const itemNameWithoutExtension = item.split(".")[0];
|
||||
if (itemNameWithoutExtension === id) {
|
||||
const filePath = `${directory}${item}`;
|
||||
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
let items = JSON.parse(downloadedItems) as DownloadedItem[];
|
||||
items = items.filter((item) => item.item.Id !== id);
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to delete file and storage entry for ID ${id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItems = async (items: BaseItemDto[]) => {
|
||||
Promise.all(
|
||||
items.map((i) => {
|
||||
if (i.Id) return deleteFile(i.Id);
|
||||
return;
|
||||
})
|
||||
).then(() =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
);
|
||||
};
|
||||
|
||||
const cleanCacheDirectory = async () => {
|
||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
||||
if (cacheDir.exists) {
|
||||
const cachedFiles = await FileSystem.readDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY)
|
||||
let position = 0
|
||||
const batchSize = 3
|
||||
|
||||
// batching promise.all to avoid OOM
|
||||
while (position < cachedFiles.length) {
|
||||
const itemsForBatch = cachedFiles.slice(position, position + batchSize)
|
||||
await Promise.all(itemsForBatch.map(async file => {
|
||||
const info = await FileSystem.getInfoAsync(`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`)
|
||||
if (info.exists) {
|
||||
await FileSystem.deleteAsync(info.uri, { idempotent: true })
|
||||
return Promise.resolve(file)
|
||||
}
|
||||
return Promise.reject()
|
||||
}))
|
||||
|
||||
position += batchSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
||||
await Promise.all(
|
||||
downloadedFiles
|
||||
?.filter((file) => file.item.Type == type)
|
||||
?.flatMap((file) => {
|
||||
const promises = [];
|
||||
if (type == "Episode" && file.item.SeriesId)
|
||||
promises.push(deleteFile(file.item.SeriesId));
|
||||
promises.push(deleteFile(file.item.Id!));
|
||||
return promises;
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const appSizeUsage = useMemo(async () => {
|
||||
const sizes: number[] = downloadedFiles?.map(d => {
|
||||
return getDownloadedItemSize(d.item.Id!!)
|
||||
}) || [];
|
||||
|
||||
await forEveryDocumentDirFile(
|
||||
true,
|
||||
getAllDownloadedItems().map(d => d.item.Id!!),
|
||||
(file) => {
|
||||
if (file.exists) {
|
||||
sizes.push(file.size);
|
||||
}
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
|
||||
|
||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
const items: DownloadedItem[] = JSON.parse(downloadedItems);
|
||||
const item = items.find((i) => i.item.Id === itemId);
|
||||
return item || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to retrieve item with ID ${itemId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllDownloadedItems(): DownloadedItem[] {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
return JSON.parse(downloadedItems) as DownloadedItem[];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve downloaded items:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: DownloadedItem[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
||||
|
||||
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
if (!data?.mediaSource)
|
||||
throw new Error(
|
||||
"Media source not found in tmp storage. Did you forget to save it before starting download?"
|
||||
);
|
||||
|
||||
const newItem = { item, mediaSource: data.mediaSource };
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = newItem;
|
||||
} else {
|
||||
items.push(newItem);
|
||||
}
|
||||
|
||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
storage.set("downloadedItemSize-" + item.Id, size.toString());
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save downloaded item information with media source:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadedItemSize(itemId: string): number {
|
||||
const size = storage.getString("downloadedItemSize-" + itemId);
|
||||
return size ? parseInt(size) : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
processes,
|
||||
startBackgroundDownload,
|
||||
downloadedFiles,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
saveDownloadedItemInfo,
|
||||
removeProcess,
|
||||
setProcesses,
|
||||
startDownload,
|
||||
getDownloadedItem,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
getDownloadedItemSize,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory
|
||||
};
|
||||
}
|
||||
|
||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
const downloadProviderValue = useDownloadProvider();
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={downloadProviderValue}>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
const context = useContext(DownloadContext);
|
||||
if (context === null) {
|
||||
throw new Error("useDownload must be used within a DownloadProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function bytesToReadable(bytes: number): string {
|
||||
const gb = bytes / 1e9;
|
||||
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
|
||||
const mb = bytes / 1024.0 / 1024.0;
|
||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||
|
||||
const kb = bytes / 1024.0;
|
||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||
return `${bytes.toFixed(2)} B`;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.22.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.23.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -91,7 +91,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.22.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.23.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { createContext } from "react";
|
||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
||||
|
||||
const JobQueueContext = createContext(null);
|
||||
|
||||
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
useJobProcessor();
|
||||
|
||||
return (
|
||||
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Alert, AppState, AppStateStatus } from "react-native";
|
||||
import { Alert } from "react-native";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -104,28 +104,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
init();
|
||||
}, [api, deviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (state: AppStateStatus) => {
|
||||
if (state === "background" || state === "inactive") {
|
||||
console.log("App moving to background, closing WebSocket...");
|
||||
ws?.close();
|
||||
} else if (state === "active") {
|
||||
console.log("App coming to foreground, reconnecting WebSocket...");
|
||||
connectWebSocket();
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
ws?.close();
|
||||
};
|
||||
}, [ws, connectWebSocket]);
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={{ ws, isConnected }}>
|
||||
{children}
|
||||
|
||||
@@ -3,15 +3,9 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
|
||||
"exclude": ["node_modules", "utils/jellyseerr/**/*.ts"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user