mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa785b0f95 | ||
|
|
8ea38a3efc | ||
|
|
07ad905e16 | ||
|
|
3058b866c3 | ||
|
|
497a1adc26 | ||
|
|
093cd94455 | ||
|
|
349a86bcfb | ||
|
|
4b81dff0be |
47
app.json
47
app.json
@@ -41,33 +41,17 @@
|
|||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": []
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
|
||||||
"android.permission.WRITE_SETTINGS"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
"@config-plugins/ffmpeg-kit-react-native",
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
"enableNotificationControls": true,
|
"enableNotificationControls": true,
|
||||||
"enableBackgroundAudio": true,
|
"enableBackgroundAudio": true
|
||||||
"androidExtensions": {
|
|
||||||
"useExoplayerRtsp": false,
|
|
||||||
"useExoplayerSmoothStreaming": false,
|
|
||||||
"useExoplayerHls": true,
|
|
||||||
"useExoplayerDash": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -76,28 +60,8 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "15.6",
|
||||||
"useFrameworks": "static"
|
"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",
|
"expo-sensors",
|
||||||
@@ -106,12 +70,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
[
|
["react-native-edge-to-edge"],
|
||||||
"react-native-edge-to-edge",
|
|
||||||
{ "android": { "parentTheme": "Material3" } }
|
|
||||||
],
|
|
||||||
["react-native-bottom-tabs"],
|
["react-native-bottom-tabs"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
["@react-native-tvos/config-tv"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"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 { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
@@ -19,7 +18,6 @@ export default function IndexLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
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
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
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 { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
@@ -64,31 +63,10 @@ export default function index() {
|
|||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
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 () => {
|
const checkConnection = useCallback(async () => {
|
||||||
setLoadingRetry(true);
|
setLoadingRetry(true);
|
||||||
const state = await NetInfo.fetch();
|
const state = await NetInfo.fetch();
|
||||||
@@ -107,9 +85,6 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanCacheDirectory()
|
|
||||||
.then(r => console.log("Cache directory cleaned"))
|
|
||||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
@@ -308,48 +283,6 @@ export default function index() {
|
|||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, collections, mediaListCollections]);
|
}, [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)
|
if (e1 || e2)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import {useDownload} from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, useLog } from "@/utils/log";
|
import { clearLogs, useLog } from "@/utils/log";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import * as Progress from "react-native-progress";
|
import * as Progress from "react-native-progress";
|
||||||
@@ -17,7 +15,6 @@ import { toast } from "sonner-native";
|
|||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -25,18 +22,6 @@ export default function settings() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
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 = () => {
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
Alert.prompt(
|
Alert.prompt(
|
||||||
"Quick connect",
|
"Quick connect",
|
||||||
@@ -49,16 +34,11 @@ export default function settings() {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
Alert.alert("Success", "Quick connect authorized");
|
||||||
} else {
|
} else {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
Alert.alert("Error", "Invalid code");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -90,13 +55,6 @@ export default function settings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
{/* <Button
|
|
||||||
onPress={() => {
|
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
registerBackgroundFetchAsync
|
|
||||||
</Button> */}
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
<Text className="font-bold text-lg mb-2">User Info</Text>
|
||||||
|
|
||||||
@@ -119,34 +77,6 @@ export default function settings() {
|
|||||||
|
|
||||||
<SettingToggles />
|
<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: {size.app.bytesToReadable()}</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: {size.remaining?.bytesToReadable()}, Total:{" "}
|
|
||||||
{size.total?.bytesToReadable()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Button color="red" onPress={onDeleteClicked}>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button color="red" onPress={onClearLogsClicked}>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -28,16 +27,6 @@ export default function page() {
|
|||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: album } = useQuery({
|
const { data: album } = useQuery({
|
||||||
queryKey: ["album", albumId, artistId],
|
queryKey: ["album", albumId, artistId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
@@ -41,10 +40,6 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [orientation, setOrientation] = useState(
|
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
@@ -174,8 +169,7 @@ const page: React.FC = () => {
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
marginBottom:
|
marginBottom: 16,
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
|
||||||
}}
|
}}
|
||||||
item={item}
|
item={item}
|
||||||
>
|
>
|
||||||
@@ -389,9 +383,7 @@ const page: React.FC = () => {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={255}
|
estimatedItemSize={255}
|
||||||
numColumns={
|
numColumns={5}
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
import React, { useCallback, useRef, useState } from "react";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { Button } from "@/components/Button";
|
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 {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackdropProps,
|
BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import { useQuery } from "@tanstack/react-query";
|
||||||
IssueType,
|
import { Image } from "expo-image";
|
||||||
IssueTypeName,
|
import { useLocalSearchParams } from "expo-router";
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
import { Input } from "@/components/common/Input";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -51,6 +50,7 @@ const Page: React.FC = () => {
|
|||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const [isIssueTypeModalVisible, setIsIssueTypeModalVisible] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: details,
|
data: details,
|
||||||
@@ -231,47 +231,68 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col space-y-2 items-start">
|
<View className="flex flex-col space-y-2 items-start">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
|
||||||
Issue Type
|
<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>
|
</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">
|
<Ionicons
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
name="chevron-down"
|
||||||
{issueType
|
size={16}
|
||||||
? IssueTypeName[issueType]
|
color="white"
|
||||||
: "Select an issue"}
|
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>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
</Modal>
|
||||||
loop={false}
|
</View>
|
||||||
side="bottom"
|
|
||||||
align="center"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Types</DropdownMenu.Label>
|
|
||||||
{Object.entries(IssueTypeName)
|
|
||||||
.reverse()
|
|
||||||
.map(([key, value], idx) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={value}
|
|
||||||
onSelect={() =>
|
|
||||||
setIssueType(key as unknown as IssueType)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{value}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { Ratings } from "@/components/Ratings";
|
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
@@ -77,32 +73,6 @@ const page: React.FC = () => {
|
|||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
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;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
@@ -12,7 +11,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
@@ -61,8 +59,6 @@ const Page = () => {
|
|||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
@@ -241,14 +237,7 @@ const Page = () => {
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf: "center",
|
||||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
? index % nrOfCols === 0
|
|
||||||
? "flex-end"
|
|
||||||
: (index + 1) % nrOfCols === 0
|
|
||||||
? "flex-start"
|
|
||||||
: "center"
|
|
||||||
: "center",
|
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { useState } from "react";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import { Modal, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
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;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
@@ -22,163 +54,167 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<DropdownMenu.Root>
|
<Modal
|
||||||
<DropdownMenu.Trigger>
|
visible={isMenuVisible}
|
||||||
<Ionicons
|
transparent
|
||||||
name="ellipsis-horizontal-outline"
|
animationType="slide"
|
||||||
size={24}
|
onRequestClose={() => {
|
||||||
color="white"
|
setIsMenuVisible(false);
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
{!activeSubmenu ? (
|
||||||
|
<>
|
||||||
|
<MenuSection title="Display" />
|
||||||
|
<MenuItem
|
||||||
|
label="Display"
|
||||||
|
onPress={() => setActiveSubmenu("display")}
|
||||||
/>
|
/>
|
||||||
</DropdownMenu.Trigger>
|
<MenuItem
|
||||||
<DropdownMenu.Content
|
label="Image style"
|
||||||
align={"end"}
|
onPress={() => setActiveSubmenu("imageStyle")}
|
||||||
alignOffset={-10}
|
/>
|
||||||
avoidCollisions={false}
|
<MenuItem
|
||||||
collisionPadding={0}
|
label="Show titles"
|
||||||
loop={false}
|
selected={settings.libraryOptions.showTitles}
|
||||||
side={"bottom"}
|
disabled={
|
||||||
sideOffset={10}
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
>
|
|
||||||
<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={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "row",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
onPress={() => {
|
||||||
<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={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "list",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "poster",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
|
||||||
Poster
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="cover-option"
|
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
|
||||||
onValueChange={() =>
|
|
||||||
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")
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
return;
|
return;
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
showTitles: newValue === "on" ? true : false,
|
showTitles: !settings.libraryOptions.showTitles,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<DropdownMenu.ItemIndicator />
|
<MenuItem
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
label="Show stats"
|
||||||
Show titles
|
selected={settings.libraryOptions.showStats}
|
||||||
</DropdownMenu.ItemTitle>
|
onPress={() => {
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="show-stats-option"
|
|
||||||
value={settings.libraryOptions.showStats}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
showStats: newValue === "on" ? true : false,
|
showStats: !settings.libraryOptions.showStats,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : activeSubmenu === "display" ? (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setActiveSubmenu(null)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<Ionicons
|
||||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
name="chevron-back"
|
||||||
Show stats
|
size={24}
|
||||||
</DropdownMenu.ItemTitle>
|
color="white"
|
||||||
</DropdownMenu.CheckboxItem>
|
/>
|
||||||
</DropdownMenu.Group>
|
</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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Cover"
|
||||||
|
selected={
|
||||||
|
settings.libraryOptions.imageStyle === "cover"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<TouchableOpacity
|
||||||
</DropdownMenu.Content>
|
className="p-4 border-t border-neutral-800"
|
||||||
</DropdownMenu.Root>
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,18 +73,6 @@ export default function TabLayout() {
|
|||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
: () => ({ 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>
|
</NativeTabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||||
@@ -13,8 +10,8 @@ import {
|
|||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
@@ -27,27 +24,17 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { Alert, Platform, View } from "react-native";
|
||||||
Alert,
|
|
||||||
BackHandler,
|
|
||||||
View,
|
|
||||||
AppState,
|
|
||||||
AppStateStatus,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import settings from "../(tabs)/(home)/settings";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
@@ -65,12 +52,10 @@ export default function page() {
|
|||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
const { getDownloadedItem } = useDownload();
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -79,17 +64,14 @@ export default function page() {
|
|||||||
subtitleIndex: subtitleIndexStr,
|
subtitleIndex: subtitleIndexStr,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
|
||||||
} = useGlobalSearchParams<{
|
} = useGlobalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const offline = offlineStr === "true";
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
@@ -104,12 +86,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
|
||||||
const item = await getDownloadedItem(itemId);
|
|
||||||
if (item) return item.item;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
itemId,
|
itemId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
@@ -128,21 +104,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
queryFn: async () => {
|
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({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -177,11 +138,10 @@ export default function page() {
|
|||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
if (!offline && stream) {
|
if (stream) {
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
@@ -199,7 +159,7 @@ export default function page() {
|
|||||||
console.log("Actually marked as paused");
|
console.log("Actually marked as paused");
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
if (!offline && stream) {
|
if (stream) {
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
@@ -223,13 +183,10 @@ export default function page() {
|
|||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
offline,
|
|
||||||
progress.value,
|
progress.value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
const currentTimeInTicks = msToTicks(progress.value);
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
@@ -250,8 +207,6 @@ export default function page() {
|
|||||||
|
|
||||||
// TODO: unused should remove.
|
// TODO: unused should remove.
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -276,8 +231,6 @@ export default function page() {
|
|||||||
|
|
||||||
progress.value = currentTime;
|
progress.value = currentTime;
|
||||||
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
const currentTimeInTicks = msToTicks(currentTime);
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
@@ -296,14 +249,10 @@ export default function page() {
|
|||||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay: togglePlay,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
@@ -328,8 +277,6 @@ export default function page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startPosition = useMemo(() => {
|
const startPosition = useMemo(() => {
|
||||||
if (offline) return 0;
|
|
||||||
|
|
||||||
return item?.UserData?.PlaybackPositionTicks
|
return item?.UserData?.PlaybackPositionTicks
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -344,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;
|
if (!settings) return null;
|
||||||
|
|
||||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
@@ -495,7 +411,6 @@ export default function page() {
|
|||||||
enableTrickplay={true}
|
enableTrickplay={true}
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||||
offline={offline}
|
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -17,7 +15,6 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -124,7 +121,6 @@ export default function page() {
|
|||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(
|
||||||
async (ticks: number) => {
|
async (ticks: number) => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
@@ -261,9 +257,6 @@ export default function page() {
|
|||||||
}, [play, stop])
|
}, [play, stop])
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
pauseVideo: pause,
|
pauseVideo: pause,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { TrackInfo } from "@/modules/vlc-player";
|
import { TrackInfo } from "@/modules/vlc-player";
|
||||||
@@ -20,7 +18,6 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -58,7 +55,6 @@ const Player = () => {
|
|||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
@@ -167,7 +163,6 @@ const Player = () => {
|
|||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
@@ -299,14 +294,10 @@ const Player = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay: togglePlay,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||||
|
|||||||
283
app/_layout.tsx
283
app/_layout.tsx
@@ -1,42 +1,19 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
import {
|
|
||||||
getOrSetDeviceId,
|
|
||||||
getTokenFromStorage,
|
|
||||||
JellyfinProvider,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
import { LogProvider } from "@/utils/log";
|
||||||
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 { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
checkForExistingDownloads,
|
|
||||||
completeHandler,
|
|
||||||
download,
|
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useFonts } from "expo-font";
|
import { useFonts } from "expo-font";
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import * as Linking from "expo-linking";
|
import { Stack } from "expo-router";
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { router, Stack } from "expo-router";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { useEffect } from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { Appearance } from "react-native";
|
||||||
import { Appearance, AppState } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
@@ -44,170 +21,6 @@ import { Toaster } from "sonner-native";
|
|||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
|
||||||
handleNotification: async () => ({
|
|
||||||
shouldShowAlert: true,
|
|
||||||
shouldPlaySound: true,
|
|
||||||
shouldSetBadge: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
function redirect(notification: Notifications.Notification) {
|
|
||||||
const url = notification.request.content.data?.url;
|
|
||||||
if (url) {
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
|
||||||
if (!isMounted || !response?.notification) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
redirect(response?.notification);
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
|
||||||
(response) => {
|
|
||||||
redirect(response.notification);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|
||||||
console.log("TaskManager ~ trigger");
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const settingsData = 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() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
@@ -245,76 +58,15 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
|
||||||
|
|
||||||
useKeepAwake();
|
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 (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<JobQueueProvider>
|
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style="light" hidden={false} />
|
<SystemBars style="light" hidden={false} />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
@@ -369,35 +121,12 @@ function Layout() {
|
|||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</JobQueueProvider>
|
|
||||||
</ActionSheetProvider>
|
</ActionSheetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</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 { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
@@ -16,6 +16,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
@@ -25,50 +27,80 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
() => audioStreams?.find((x) => x.Index === selected),
|
() => audioStreams?.find((x) => x.Index === selected),
|
||||||
[audioStreams, selected]
|
[audioStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
style={{
|
style={{
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
<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">
|
<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}>
|
<Text className="" numberOfLines={1}>
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</View>
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
<Modal
|
||||||
side="bottom"
|
visible={isModalVisible}
|
||||||
align="start"
|
transparent
|
||||||
alignOffset={0}
|
animationType="slide"
|
||||||
avoidCollisions={true}
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
<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) => (
|
{audioStreams?.map((audio, idx: number) => (
|
||||||
<DropdownMenu.Item
|
<TouchableOpacity
|
||||||
key={idx.toString()}
|
key={idx.toString()}
|
||||||
onSelect={() => {
|
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between`}
|
||||||
if (audio.Index !== null && audio.Index !== undefined)
|
onPress={() => {
|
||||||
|
if (audio.Index !== null && audio.Index !== undefined) {
|
||||||
onChange(audio.Index);
|
onChange(audio.Index);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<Text>{audio.DisplayTitle}</Text>
|
||||||
{audio.DisplayTitle}
|
{audio.Index === selected && (
|
||||||
</DropdownMenu.ItemTitle>
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
</DropdownMenu.Item>
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</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,7 +1,7 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View, Modal } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -49,6 +49,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
@@ -57,9 +59,10 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||||
);
|
);
|
||||||
}, []);
|
}, [inverted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
style={{
|
style={{
|
||||||
@@ -67,39 +70,70 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
<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">
|
<TouchableOpacity
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
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}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</View>
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
<Modal
|
||||||
side="bottom"
|
visible={isModalVisible}
|
||||||
align="center"
|
transparent
|
||||||
alignOffset={0}
|
animationType="slide"
|
||||||
avoidCollisions={true}
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
<TouchableOpacity
|
||||||
{sorted.map((b) => (
|
className="flex-1 bg-black/50"
|
||||||
<DropdownMenu.Item
|
activeOpacity={1}
|
||||||
key={b.key}
|
onPress={() => setIsModalVisible(false)}
|
||||||
onSelect={() => {
|
>
|
||||||
onChange(b);
|
<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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
<Text>{bitrate.key}</Text>
|
||||||
</DropdownMenu.Item>
|
{bitrate.value === selected?.value && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</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,4 +1,3 @@
|
|||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
@@ -54,7 +53,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!loading && !disabled && onPress) {
|
if (!loading && !disabled && onPress) {
|
||||||
onPress();
|
onPress();
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled || loading}
|
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,6 +1,5 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
@@ -12,8 +11,6 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -24,12 +21,10 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
@@ -46,10 +41,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
({ item }) => {
|
({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { orientation } = useOrientation();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
useImageColors({ item });
|
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
@@ -85,10 +78,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item && (
|
item && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast background="blur" width={22} height={22} />
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadSingleItem item={item} size="large" />
|
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -98,11 +89,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
setHeaderHeight(230);
|
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
|
||||||
else setHeaderHeight(350);
|
else setHeaderHeight(350);
|
||||||
}, [item.Type, orientation]);
|
}, [item.Type]);
|
||||||
|
|
||||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -21,6 +20,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const selectedName = useMemo(
|
const selectedName = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
@@ -30,48 +31,80 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
style={{
|
style={{
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
<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">
|
<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>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</View>
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
<Modal
|
||||||
side="bottom"
|
visible={isModalVisible}
|
||||||
align="start"
|
transparent
|
||||||
alignOffset={0}
|
animationType="slide"
|
||||||
avoidCollisions={true}
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
<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) => (
|
{item.MediaSources?.map((source, idx: number) => (
|
||||||
<DropdownMenu.Item
|
<TouchableOpacity
|
||||||
key={idx.toString()}
|
key={idx.toString()}
|
||||||
onSelect={() => {
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
onChange(source);
|
onChange(source);
|
||||||
|
setIsModalVisible(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<Text>
|
||||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||||
source.Size
|
source.Size
|
||||||
)}`}
|
)}`}
|
||||||
</DropdownMenu.ItemTitle>
|
</Text>
|
||||||
</DropdownMenu.Item>
|
{source.Id === selected?.Id && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</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,24 +1,14 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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 { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
|
||||||
CastButton,
|
|
||||||
PlayServicesState,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -31,8 +21,6 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -48,8 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -79,8 +65,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -91,137 +75,16 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
client,
|
|
||||||
settings,
|
settings,
|
||||||
api,
|
api,
|
||||||
user,
|
user,
|
||||||
router,
|
router,
|
||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -355,13 +218,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name="play-circle" size={24} />
|
<Ionicons name="play-circle" size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{client && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<Feather name="cast" size={22} />
|
|
||||||
<CastButton tintColor="transparent" />
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
{!client && settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="vlc"
|
name="vlc"
|
||||||
@@ -369,7 +225,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
color={animatedTextStyle.color}
|
color={animatedTextStyle.color}
|
||||||
/>
|
/>
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress?: () => void,
|
onPress?: () => void;
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
@@ -32,7 +31,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
if (hapticFeedback) {
|
if (hapticFeedback) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
}
|
||||||
onPress?.();
|
onPress?.();
|
||||||
};
|
};
|
||||||
@@ -98,7 +96,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<BlurView
|
<BlurView
|
||||||
intensity={90}
|
intensity={90}
|
||||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View, Modal } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -20,6 +20,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
isTranscoding,
|
isTranscoding,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<View
|
<View
|
||||||
className="flex col shrink justify-start place-self-start items-start"
|
className="flex col shrink justify-start place-self-start items-start"
|
||||||
style={{
|
style={{
|
||||||
@@ -45,52 +48,90 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
|
<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">
|
<TouchableOpacity
|
||||||
<Text className=" ">
|
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
|
{selectedSubtitleSteam
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
: "None"}
|
: "None"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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>
|
</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>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
@@ -26,78 +28,49 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
const autoApprove = useMemo(() => {
|
||||||
return jellyseerrUser && hasPermission(
|
return (
|
||||||
Permission.AUTO_APPROVE,
|
jellyseerrUser &&
|
||||||
jellyseerrUser.permissions,
|
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
||||||
{type: 'or'}
|
type: "or",
|
||||||
)
|
})
|
||||||
}, [jellyseerrApi, jellyseerrUser])
|
);
|
||||||
|
}, [jellyseerrApi, jellyseerrUser]);
|
||||||
|
|
||||||
const request = useCallback(() =>
|
const request = useCallback(
|
||||||
|
() =>
|
||||||
requestMedia(mediaTitle, {
|
requestMedia(mediaTitle, {
|
||||||
mediaId: result.id,
|
mediaId: result.id,
|
||||||
mediaType: result.mediaType
|
mediaType: result.mediaType,
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
[jellyseerrApi, result]
|
[jellyseerrApi, result]
|
||||||
)
|
);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
router.push({
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
|
params: {
|
||||||
|
...result,
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest,
|
||||||
|
posterSrc,
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
|
||||||
alignOffset={0}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={false}
|
|
||||||
key={"content"}
|
|
||||||
>
|
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
|
||||||
{canRequest && result.mediaType === MediaType.MOVIE && (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key="item-1"
|
|
||||||
onSelect={() => {
|
|
||||||
if (autoApprove) {
|
|
||||||
request()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "arrow.down.to.line",
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "purple",
|
|
||||||
light: "purple",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName="download"
|
|
||||||
/>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
@@ -68,8 +67,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
@@ -80,66 +77,5 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { 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(s.bytesToReadable());
|
|
||||||
}, [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 { Text } from "@/components/common/Text";
|
||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
|||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -147,7 +146,6 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const handleRoute = useCallback(() => {
|
const handleRoute = useCallback(() => {
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (url) router.push(url);
|
if (url) router.push(url);
|
||||||
}, [item, from]);
|
}, [item, from]);
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ import { useRouter } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
@@ -32,40 +27,14 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const { setPlaySettings } = usePlaySettings();
|
const { setPlaySettings } = usePlaySettings();
|
||||||
|
|
||||||
const openSelect = () => {
|
const openSelect = () => {
|
||||||
if (!castDevice?.deviceId) {
|
|
||||||
play("device");
|
play("device");
|
||||||
return;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = useCallback(async (type: "device" | "cast") => {
|
const play = useCallback(async (type: "device" | "cast") => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View, Modal } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -29,6 +29,8 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
state,
|
state,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const keys = useMemo<SeasonKeys>(
|
const keys = useMemo<SeasonKeys>(
|
||||||
() =>
|
() =>
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
@@ -55,7 +57,6 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
let initialIndex: number | undefined;
|
let initialIndex: number | undefined;
|
||||||
|
|
||||||
if (initialSeasonIndex !== undefined) {
|
if (initialSeasonIndex !== undefined) {
|
||||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
|
||||||
const seasonExists = seasons.some(
|
const seasonExists = seasons.some(
|
||||||
(season: any) => season[keys.index] === initialSeasonIndex
|
(season: any) => season[keys.index] === initialSeasonIndex
|
||||||
);
|
);
|
||||||
@@ -65,7 +66,6 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (initialIndex === undefined) {
|
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 season1 = seasons.find((season: any) => season[keys.index] === 1);
|
||||||
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
||||||
const firstSeason = season1 || season0 || seasons[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]);
|
Number(a[keys.index]) - Number(b[keys.index]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<>
|
||||||
<DropdownMenu.Trigger>
|
<TouchableOpacity
|
||||||
<View className="flex flex-row">
|
className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<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)}
|
||||||
|
>
|
||||||
<Text>Season {seasonIndex}</Text>
|
<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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
</Modal>
|
||||||
loop={true}
|
</>
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
|
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "../common/Text";
|
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 { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import {
|
|
||||||
SeasonDropdown,
|
|
||||||
SeasonIndexState,
|
|
||||||
} from "@/components/series/SeasonDropdown";
|
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -143,19 +141,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{episodes?.length || 0 > 0 ? (
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
<View className="px-4 flex flex-col mt-4">
|
<View className="px-4 flex flex-col mt-4">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
@@ -193,9 +178,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start ml-auto -mt-0.5">
|
|
||||||
<DownloadSingleItem item={e} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps, Modal } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -17,62 +19,27 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
<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-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
<View
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Audio language</Text>
|
<Text className="font-semibold">Audio language</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Choose a default audio language.
|
Choose a default audio language.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<TouchableOpacity
|
||||||
<DropdownMenu.Trigger>
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
onPress={() => setIsModalVisible(true)}
|
||||||
<Text>
|
>
|
||||||
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
<Text>{settings?.defaultAudioLanguage?.DisplayName || "None"}</Text>
|
||||||
</Text>
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</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>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -89,6 +56,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -109,6 +77,71 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,93 +1,38 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
apiAtom,
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
|
Modal,
|
||||||
Switch,
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
ViewProps,
|
ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
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 { AudioToggles } from "./AudioToggles";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { ListItem } from "@/components/ListItem";
|
|
||||||
import { JellyseerrSettings } from "./Jellyseerr";
|
import { JellyseerrSettings } from "./Jellyseerr";
|
||||||
|
import { MediaProvider } from "./MediaContext";
|
||||||
|
import { MediaToggles } from "./MediaToggles";
|
||||||
|
import { SubtitleToggles } from "./SubtitleToggles";
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const { setProcesses } = useDownload();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [isSearchEngineModalVisible, setIsSearchEngineModalVisible] =
|
||||||
/********************
|
useState(false);
|
||||||
* 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 {
|
const {
|
||||||
data: mediaListCollections,
|
data: mediaListCollections,
|
||||||
@@ -111,6 +56,13 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
staleTime: 0,
|
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;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,113 +106,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</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="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="shrink">
|
<View className="shrink">
|
||||||
<Text className="font-semibold">Safe area in controls</Text>
|
<Text className="font-semibold">Safe area in controls</Text>
|
||||||
@@ -347,54 +192,27 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Search engine</Text>
|
<Text className="font-semibold">Search engine</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Choose the search engine you want to use.
|
Choose the search engine you want to use.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<TouchableOpacity
|
||||||
<DropdownMenu.Trigger>
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
onPress={() => setIsSearchEngineModalVisible(true)}
|
||||||
|
>
|
||||||
<Text>{settings.searchEngine}</Text>
|
<Text>{settings.searchEngine}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Marlin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{settings.searchEngine === "Marlin" && (
|
{settings.searchEngine === "Marlin" && (
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
@@ -434,209 +252,56 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<Modal
|
||||||
<View className="shrink">
|
visible={isSearchEngineModalVisible}
|
||||||
<Text className="font-semibold">Show Custom Menu Links</Text>
|
transparent
|
||||||
<Text className="text-xs opacity-50">
|
animationType="slide"
|
||||||
Show custom menu links defined inside your Jellyfin web
|
onRequestClose={() => setIsSearchEngineModalVisible(false)}
|
||||||
config.json file
|
>
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
className="flex-1 bg-black/50"
|
||||||
Linking.openURL(
|
activeOpacity={1}
|
||||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Text className="text-xs text-purple-600">More info</Text>
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
</TouchableOpacity>
|
<View className="p-4 border-b border-neutral-800">
|
||||||
</View>
|
<Text className="text-lg font-bold text-center">
|
||||||
<Switch
|
Select Search Engine
|
||||||
value={settings.showCustomMenuLinks}
|
</Text>
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ showCustomMenuLinks: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="mt-4">
|
<View className="max-h-[50%]">
|
||||||
<Text className="text-lg font-bold mb-2">Downloads</Text>
|
{searchEngines.map((engine) => (
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
<TouchableOpacity
|
||||||
<View
|
key={engine.id}
|
||||||
className={`
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
onPress={() => {
|
||||||
`}
|
updateSettings({
|
||||||
>
|
searchEngine: engine.id,
|
||||||
<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"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
setIsSearchEngineModalVisible(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
<Text>{engine.name}</Text>
|
||||||
</DropdownMenu.Item>
|
{settings.searchEngine === engine.id && (
|
||||||
</DropdownMenu.Content>
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
</DropdownMenu.Root>
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
<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
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Set the URL for the optimized versions server for downloads.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View></View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Input
|
|
||||||
placeholder="Optimized versions server URL..."
|
|
||||||
value={optimizedVersionsServerUrl}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
className="h-12 mt-2"
|
|
||||||
onPress={async () => {
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl:
|
|
||||||
optimizedVersionsServerUrl.length === 0
|
|
||||||
? null
|
|
||||||
: optimizedVersionsServerUrl.endsWith("/")
|
|
||||||
? optimizedVersionsServerUrl
|
|
||||||
: optimizedVersionsServerUrl + "/",
|
|
||||||
});
|
|
||||||
const res = await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: await getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
if (res) {
|
|
||||||
toast.success("Connected");
|
|
||||||
} else toast.error("Could not connect");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
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 { Text } from "../common/Text";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
|
const [isLanguageModalVisible, setIsLanguageModalVisible] = useState(false);
|
||||||
|
const [isModeModalVisible, setIsModeModalVisible] = useState(false);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const subtitleModes = [
|
const subtitleModes = [
|
||||||
@@ -25,68 +29,30 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
<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-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
<View
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Subtitle language</Text>
|
<Text className="font-semibold">Subtitle language</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Choose a default subtitle language.
|
Choose a default subtitle language.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<TouchableOpacity
|
||||||
<DropdownMenu.Trigger>
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
onPress={() => setIsLanguageModalVisible(true)}
|
||||||
|
>
|
||||||
<Text>
|
<Text>
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</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>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Subtitle Mode</Text>
|
<Text className="font-semibold">Subtitle Mode</Text>
|
||||||
<Text className="text-xs opacity-50 mr-2">
|
<Text className="text-xs opacity-50 mr-2">
|
||||||
@@ -95,36 +61,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
multiple options are available.
|
multiple options are available.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<TouchableOpacity
|
||||||
<DropdownMenu.Trigger>
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
onPress={() => setIsModeModalVisible(true)}
|
||||||
|
>
|
||||||
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</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>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -186,6 +134,119 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -109,7 +108,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
setSubtitleTrack,
|
setSubtitleTrack,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
stop,
|
stop,
|
||||||
offline = false,
|
|
||||||
enableTrickplay = true,
|
enableTrickplay = true,
|
||||||
isVlc = false,
|
isVlc = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -124,7 +122,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
calculateTrickplayUrl,
|
calculateTrickplayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
prefetchAllTrickplayImages,
|
prefetchAllTrickplayImages,
|
||||||
} = useTrickplay(item, !offline && enableTrickplay);
|
} = useTrickplay(item, enableTrickplay);
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [remainingTime, setRemainingTime] = useState(Infinity);
|
const [remainingTime, setRemainingTime] = useState(Infinity);
|
||||||
@@ -142,7 +140,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
offline ? undefined : item.Id,
|
item.Id,
|
||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
@@ -150,7 +148,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
offline ? undefined : item.Id,
|
item.Id,
|
||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
@@ -160,8 +158,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
if (!previousItem || !settings) return;
|
if (!previousItem || !settings) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||||
@@ -198,8 +194,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
if (!nextItem || !settings) return;
|
if (!nextItem || !settings) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||||
@@ -326,7 +320,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const handleSkipBackward = useCallback(async () => {
|
const handleSkipBackward = useCallback(async () => {
|
||||||
if (!settings?.rewindSkipTime) return;
|
if (!settings?.rewindSkipTime) return;
|
||||||
wasPlayingRef.current = isPlaying;
|
wasPlayingRef.current = isPlaying;
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
@@ -344,7 +338,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const handleSkipForward = useCallback(async () => {
|
const handleSkipForward = useCallback(async () => {
|
||||||
if (!settings?.forwardSkipTime) return;
|
if (!settings?.forwardSkipTime) return;
|
||||||
wasPlayingRef.current = isPlaying;
|
wasPlayingRef.current = isPlaying;
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
@@ -361,7 +355,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const memoizedRenderBubble = useCallback(() => {
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
@@ -440,8 +433,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const gotoItem = await getItemById(api, itemId);
|
const gotoItem = await getItemById(api, itemId);
|
||||||
if (!settings || !gotoItem) return;
|
if (!settings || !gotoItem) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||||
@@ -547,7 +538,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||||
>
|
>
|
||||||
{item?.Type === "Episode" && !offline && (
|
{item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
switchOnEpisodeMode();
|
switchOnEpisodeMode();
|
||||||
@@ -557,7 +548,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
<Ionicons name="list" size={24} color="white" />
|
<Ionicons name="list" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{previousItem && !offline && (
|
{previousItem && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={goToPreviousItem}
|
onPress={goToPreviousItem}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
@@ -566,7 +557,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{nextItem && !offline && (
|
{nextItem && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={goToNextItem}
|
onPress={goToNextItem}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
@@ -589,7 +580,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
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 ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Loader } from "@/components/Loader";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
HorizontalScrollRef,
|
HorizontalScrollRef,
|
||||||
} from "@/components/common/HorrizontalScroll";
|
} from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import {
|
import {
|
||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
SeasonIndexState,
|
SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} 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 = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -33,7 +31,6 @@ export const seasonIndexAtom = atom<SeasonIndexState>({});
|
|||||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets(); // Get safe area insets
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||||
const scrollToIndex = (index: number) => {
|
const scrollToIndex = (index: number) => {
|
||||||
@@ -233,9 +230,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start mt-2">
|
|
||||||
<DownloadSingleItem item={_item} />
|
|
||||||
</View>
|
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={5}
|
numberOfLines={5}
|
||||||
className="text-xs text-neutral-500 shrink"
|
className="text-xs text-neutral-500 shrink"
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
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 { Ionicons } from "@expo/vector-icons";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
interface DropdownViewDirectProps {
|
interface DropdownViewDirectProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||||
showControls,
|
showControls,
|
||||||
offline = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
|
||||||
|
const [activeSubMenu, setActiveSubMenu] = useState<
|
||||||
|
"subtitle" | "audio" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
const mediaSource = ControlContext?.mediaSource;
|
||||||
@@ -53,15 +56,11 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
deliveryUrl: s.DeliveryUrl,
|
deliveryUrl: s.DeliveryUrl,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Combine embedded subs with external subs only if not offline
|
|
||||||
if (!offline) {
|
|
||||||
return [...embeddedSubs, ...externalSubs] as (
|
return [...embeddedSubs, ...externalSubs] as (
|
||||||
| EmbeddedSubtitle
|
| EmbeddedSubtitle
|
||||||
| ExternalSubtitle
|
| ExternalSubtitle
|
||||||
)[];
|
)[];
|
||||||
}
|
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||||
return embeddedSubs as EmbeddedSubtitle[];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -71,87 +70,143 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
return (
|
const closeAllModals = () => {
|
||||||
<DropdownMenu.Root>
|
setIsMainModalVisible(false);
|
||||||
<DropdownMenu.Trigger>
|
setActiveSubMenu(null);
|
||||||
<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"} />
|
|
||||||
|
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>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
);
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
return (
|
||||||
side="bottom"
|
<>
|
||||||
align="start"
|
<TouchableOpacity
|
||||||
alignOffset={0}
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
avoidCollisions={true}
|
onPress={() => setIsMainModalVisible(true)}
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Sub>
|
<Ionicons name="ellipsis-horizontal" size={24} color="white" />
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
</TouchableOpacity>
|
||||||
Subtitle
|
|
||||||
</DropdownMenu.SubTrigger>
|
<Modal
|
||||||
<DropdownMenu.SubContent
|
visible={isMainModalVisible}
|
||||||
alignOffset={-10}
|
transparent
|
||||||
avoidCollisions={true}
|
animationType="slide"
|
||||||
collisionPadding={0}
|
onRequestClose={closeAllModals}
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
>
|
||||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
<TouchableOpacity
|
||||||
<DropdownMenu.CheckboxItem
|
className="flex-1 bg-black/50"
|
||||||
key={`subtitle-item-${idx}`}
|
activeOpacity={1}
|
||||||
value={subtitleIndex === sub.index.toString()}
|
onPress={closeAllModals}
|
||||||
onValueChange={() => {
|
>
|
||||||
|
<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) {
|
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||||
setSubtitleURL &&
|
setSubtitleURL?.(
|
||||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
api?.basePath + sub.deliveryUrl,
|
||||||
|
sub.name
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
setSubtitleTrack?.(sub.index);
|
||||||
}
|
}
|
||||||
router.setParams({
|
router.setParams({
|
||||||
subtitleIndex: sub.index.toString(),
|
subtitleIndex: sub.index.toString(),
|
||||||
});
|
});
|
||||||
|
closeAllModals();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
<Text>{sub.name}</Text>
|
||||||
{sub.name}
|
{subtitleIndex === sub.index.toString() && (
|
||||||
</DropdownMenu.ItemTitle>
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
</DropdownMenu.CheckboxItem>
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.SubContent>
|
</View>
|
||||||
</DropdownMenu.Sub>
|
</>
|
||||||
<DropdownMenu.Sub>
|
) : (
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
<>
|
||||||
Audio
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
</DropdownMenu.SubTrigger>
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
<DropdownMenu.SubContent
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
alignOffset={-10}
|
</TouchableOpacity>
|
||||||
avoidCollisions={true}
|
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||||
collisionPadding={0}
|
</View>
|
||||||
loop={true}
|
<View className="max-h-[50%]">
|
||||||
sideOffset={10}
|
{audioTracks?.map((track, idx) => (
|
||||||
>
|
<TouchableOpacity
|
||||||
{audioTracks?.map((track, idx: number) => (
|
key={`audio-${idx}`}
|
||||||
<DropdownMenu.CheckboxItem
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
key={`audio-item-${idx}`}
|
onPress={() => {
|
||||||
value={audioIndex === track.index.toString()}
|
setAudioTrack?.(track.index);
|
||||||
onValueChange={() => {
|
|
||||||
setAudioTrack && setAudioTrack(track.index);
|
|
||||||
router.setParams({
|
router.setParams({
|
||||||
audioIndex: track.index.toString(),
|
audioIndex: track.index.toString(),
|
||||||
});
|
});
|
||||||
|
closeAllModals();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
<Text>{track.name}</Text>
|
||||||
{track.name}
|
{audioIndex === track.index.toString() && (
|
||||||
</DropdownMenu.ItemTitle>
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
</DropdownMenu.CheckboxItem>
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.SubContent>
|
</View>
|
||||||
</DropdownMenu.Sub>
|
</>
|
||||||
</DropdownMenu.Content>
|
)}
|
||||||
</DropdownMenu.Root>
|
|
||||||
|
<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 { Text } from "@/components/common/Text";
|
||||||
import { View, TouchableOpacity } from "react-native";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import { TranscodedSubtitle } from "../types";
|
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 {
|
interface DropdownViewProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||||
|
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
|
||||||
|
const [activeSubMenu, setActiveSubMenu] = useState<
|
||||||
|
"subtitle" | "audio" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
@@ -117,6 +121,27 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
[mediaSource, subtitleIndex, audioIndex]
|
[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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -126,45 +151,57 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
}}
|
}}
|
||||||
className="p-4"
|
className="p-4"
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<TouchableOpacity
|
||||||
<DropdownMenu.Trigger>
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
<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"} />
|
>
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
<Modal
|
||||||
loop={true}
|
visible={isMainModalVisible}
|
||||||
side="bottom"
|
transparent
|
||||||
align="start"
|
animationType="slide"
|
||||||
alignOffset={0}
|
onRequestClose={closeAllModals}
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Sub>
|
<TouchableOpacity
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
className="flex-1 bg-black/50"
|
||||||
Subtitle
|
activeOpacity={1}
|
||||||
</DropdownMenu.SubTrigger>
|
onPress={closeAllModals}
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
>
|
||||||
{allSubtitleTracksForTranscodingStream?.map(
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
(sub, idx: number) => (
|
{!activeSubMenu ? (
|
||||||
<DropdownMenu.CheckboxItem
|
<>
|
||||||
value={
|
<View className="p-4 border-b border-neutral-800">
|
||||||
subtitleIndex ===
|
<Text className="text-lg font-bold text-center">
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
Settings
|
||||||
? subtitleHelper
|
</Text>
|
||||||
.getSourceSubtitleIndex(sub.index)
|
</View>
|
||||||
.toString()
|
<View>
|
||||||
: sub?.index.toString())
|
<MenuOption
|
||||||
}
|
label="Subtitle"
|
||||||
key={`subtitle-item-${idx}`}
|
onPress={() => setActiveSubMenu("subtitle")}
|
||||||
onValueChange={() => {
|
/>
|
||||||
|
<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 (
|
if (
|
||||||
subtitleIndex ===
|
subtitleIndex ===
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
@@ -183,51 +220,66 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
|
|
||||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
changeToImageBasedSub(sub.index);
|
changeToImageBasedSub(sub.index);
|
||||||
|
}
|
||||||
|
closeAllModals();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
<Text>{sub.name}</Text>
|
||||||
{sub.name}
|
{subtitleIndex ===
|
||||||
</DropdownMenu.ItemTitle>
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
</DropdownMenu.CheckboxItem>
|
? subtitleHelper
|
||||||
)
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString()
|
||||||
|
: sub?.index.toString()) && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.SubContent>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Sub>
|
))}
|
||||||
<DropdownMenu.Sub>
|
</View>
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
</>
|
||||||
Audio
|
) : (
|
||||||
</DropdownMenu.SubTrigger>
|
<>
|
||||||
<DropdownMenu.SubContent
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
alignOffset={-10}
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
avoidCollisions={true}
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
collisionPadding={0}
|
</TouchableOpacity>
|
||||||
loop={true}
|
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||||
sideOffset={10}
|
</View>
|
||||||
>
|
<View className="max-h-[50%]">
|
||||||
{allAudio?.map((track, idx: number) => (
|
{allAudio?.map((track, idx) => (
|
||||||
<DropdownMenu.CheckboxItem
|
<TouchableOpacity
|
||||||
key={`audio-item-${idx}`}
|
key={`audio-${idx}`}
|
||||||
value={audioIndex === track.index.toString()}
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
onValueChange={() => {
|
onPress={() => {
|
||||||
if (audioIndex === track.index.toString()) return;
|
if (audioIndex === track.index.toString()) return;
|
||||||
router.setParams({
|
router.setParams({
|
||||||
audioIndex: track.index.toString(),
|
audioIndex: track.index.toString(),
|
||||||
});
|
});
|
||||||
ChangeTranscodingAudio(track.index);
|
ChangeTranscodingAudio(track.index);
|
||||||
|
closeAllModals();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
<Text>{track.name}</Text>
|
||||||
{track.name}
|
{audioIndex === track.index.toString() && (
|
||||||
</DropdownMenu.ItemTitle>
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
</DropdownMenu.CheckboxItem>
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.SubContent>
|
</View>
|
||||||
</DropdownMenu.Sub>
|
</>
|
||||||
</DropdownMenu.Content>
|
)}
|
||||||
</DropdownMenu.Root>
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={closeAllModals}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Introduction: {
|
Introduction: {
|
||||||
@@ -79,7 +78,6 @@ export const useCreditSkipper = (
|
|||||||
if (!creditTimestamps) return;
|
if (!creditTimestamps) return;
|
||||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||||
try {
|
try {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
wrappedSeek(creditTimestamps.Credits.End);
|
wrappedSeek(creditTimestamps.Credits.End);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
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 { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
EpisodeId: string;
|
EpisodeId: string;
|
||||||
@@ -78,7 +77,6 @@ export const useIntroSkipper = (
|
|||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
wrappedSeek(introTimestamps.IntroEnd);
|
wrappedSeek(introTimestamps.IntroEnd);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
|||||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
||||||
@@ -29,8 +28,6 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = async (played: boolean) => {
|
const markAsPlayedStatus = async (played: boolean) => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["item", item.Id],
|
["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;
|
isPlaying: boolean;
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
stopPlayback: () => void;
|
stopPlayback: () => void;
|
||||||
offline: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWebSocket = ({
|
export const useWebSocket = ({
|
||||||
isPlaying,
|
isPlaying,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
offline,
|
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { ws } = useWebSocketContext();
|
const { ws } = useWebSocketContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (!ws) return;
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Pod::Spec.new do |s|
|
|||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
s.dependency 'MobileVLCKit', '~> 3.6.1b1'
|
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1'
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
# Swift/Objective-C compatibility
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
import MobileVLCKit
|
import TVVLCKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class VlcPlayerView: ExpoView {
|
class VlcPlayerView: ExpoView {
|
||||||
|
|||||||
22
package.json
22
package.json
@@ -24,7 +24,6 @@
|
|||||||
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
||||||
"@gorhom/bottom-sheet": "^4.6.4",
|
"@gorhom/bottom-sheet": "^4.6.4",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@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-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.6",
|
"@react-native-menu/menu": "^1.1.6",
|
||||||
@@ -39,7 +38,6 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"expo": "~51.0.39",
|
"expo": "~51.0.39",
|
||||||
"expo-asset": "~10.0.10",
|
"expo-asset": "~10.0.10",
|
||||||
"expo-background-fetch": "~12.0.1",
|
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-brightness": "~12.0.1",
|
"expo-brightness": "~12.0.1",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
@@ -47,22 +45,17 @@
|
|||||||
"expo-dev-client": "~4.0.29",
|
"expo-dev-client": "~4.0.29",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.10",
|
"expo-font": "~12.0.10",
|
||||||
"expo-haptics": "~13.0.1",
|
|
||||||
"expo-image": "~1.13.0",
|
"expo-image": "~1.13.0",
|
||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linear-gradient": "~13.0.2",
|
"expo-linear-gradient": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
"expo-network": "~6.0.1",
|
"expo-network": "~6.0.1",
|
||||||
"expo-notifications": "~0.28.19",
|
|
||||||
"expo-router": "~3.5.24",
|
"expo-router": "~3.5.24",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-sensors": "~13.0.9",
|
||||||
"expo-splash-screen": "~0.27.7",
|
"expo-splash-screen": "~0.27.7",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "^3.0.7",
|
"expo-system-ui": "^3.0.7",
|
||||||
"expo-task-manager": "~11.8.2",
|
|
||||||
"expo-updates": "~0.25.27",
|
"expo-updates": "~0.25.27",
|
||||||
"expo-web-browser": "~13.0.3",
|
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.10.1",
|
||||||
@@ -70,7 +63,7 @@
|
|||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "npm:react-native-tvos@0.74.5-0",
|
||||||
"react-native-awesome-slider": "^2.5.6",
|
"react-native-awesome-slider": "^2.5.6",
|
||||||
"react-native-bottom-tabs": "0.7.1",
|
"react-native-bottom-tabs": "0.7.1",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
@@ -79,9 +72,7 @@
|
|||||||
"react-native-edge-to-edge": "^1.1.1",
|
"react-native-edge-to-edge": "^1.1.1",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^2.5.2",
|
|
||||||
"react-native-ios-utilities": "^4.5.1",
|
"react-native-ios-utilities": "^4.5.1",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "^2.12.2",
|
||||||
"react-native-pager-view": "6.3.0",
|
"react-native-pager-view": "6.3.0",
|
||||||
@@ -109,6 +100,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.0",
|
"@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/jest": "^29.5.14",
|
||||||
"@types/react": "~18.2.79",
|
"@types/react": "~18.2.79",
|
||||||
"@types/react-test-renderer": "^18.0.7",
|
"@types/react-test-renderer": "^18.0.7",
|
||||||
@@ -119,5 +112,12 @@
|
|||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,716 +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");
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
|
|
||||||
.then((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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
useMemo,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Alert, AppState, AppStateStatus } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -104,28 +104,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
init();
|
init();
|
||||||
}, [api, deviceId]);
|
}, [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 (
|
return (
|
||||||
<WebSocketContext.Provider value={{ ws, isConnected }}>
|
<WebSocketContext.Provider value={{ ws, isConnected }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -3,15 +3,9 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./*"]
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
|
||||||
"**/*.ts",
|
"exclude": ["node_modules", "utils/jellyseerr/**/*.ts"]
|
||||||
"**/*.tsx",
|
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Orientation, OrientationLock } from "expo-screen-orientation";
|
|
||||||
|
|
||||||
function orientationToOrientationLock(
|
|
||||||
orientation: Orientation
|
|
||||||
): OrientationLock {
|
|
||||||
switch (orientation) {
|
|
||||||
case Orientation.PORTRAIT_UP:
|
|
||||||
return OrientationLock.PORTRAIT_UP;
|
|
||||||
case Orientation.PORTRAIT_DOWN:
|
|
||||||
return OrientationLock.PORTRAIT_DOWN;
|
|
||||||
case Orientation.LANDSCAPE_LEFT:
|
|
||||||
return OrientationLock.LANDSCAPE_LEFT;
|
|
||||||
case Orientation.LANDSCAPE_RIGHT:
|
|
||||||
return OrientationLock.LANDSCAPE_RIGHT;
|
|
||||||
case Orientation.UNKNOWN:
|
|
||||||
default:
|
|
||||||
return OrientationLock.DEFAULT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default orientationToOrientationLock;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
export const orientationAtom = atom<number>(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import {JobStatus} from "@/utils/optimize-server";
|
|
||||||
import {processesAtom} from "@/providers/DownloadProvider";
|
|
||||||
import {useSettings} from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export interface Job {
|
|
||||||
id: string;
|
|
||||||
item: BaseItemDto;
|
|
||||||
execute: () => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const runningAtom = atom<boolean>(false);
|
|
||||||
|
|
||||||
export const queueAtom = atom<Job[]>([]);
|
|
||||||
|
|
||||||
export const queueActions = {
|
|
||||||
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
|
|
||||||
const updatedQueue = [...queue, ...job];
|
|
||||||
console.info("Enqueueing job", job, updatedQueue);
|
|
||||||
setQueue(updatedQueue);
|
|
||||||
},
|
|
||||||
processJob: async (
|
|
||||||
queue: Job[],
|
|
||||||
setQueue: (update: Job[]) => void,
|
|
||||||
setProcessing: (processing: boolean) => void
|
|
||||||
) => {
|
|
||||||
const [job, ...rest] = queue;
|
|
||||||
|
|
||||||
console.info("Processing job", job);
|
|
||||||
|
|
||||||
setProcessing(true);
|
|
||||||
|
|
||||||
// Allow job to execute so that it gets added as a processes first BEFORE updating new queue
|
|
||||||
try {
|
|
||||||
await job.execute();
|
|
||||||
} finally {
|
|
||||||
setQueue(rest);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info("Job done", job);
|
|
||||||
|
|
||||||
setProcessing(false);
|
|
||||||
},
|
|
||||||
clear: (
|
|
||||||
setQueue: (update: Job[]) => void,
|
|
||||||
setProcessing: (processing: boolean) => void
|
|
||||||
) => {
|
|
||||||
setQueue([]);
|
|
||||||
setProcessing(false);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useJobProcessor = () => {
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const [running, setRunning] = useAtom(runningAtom);
|
|
||||||
const [processes] = useAtom<JobStatus[]>(processesAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
|
|
||||||
console.info("Processing queue", queue);
|
|
||||||
queueActions.processJob(queue, setQueue, setRunning);
|
|
||||||
}
|
|
||||||
}, [processes, queue, running, setQueue, setRunning]);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { storage } from "../mmkv";
|
import { storage } from "../mmkv";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -8,44 +7,6 @@ import {
|
|||||||
SubtitlePlaybackMode,
|
SubtitlePlaybackMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
export type DownloadQuality = "original" | "high" | "low";
|
|
||||||
|
|
||||||
export type DownloadOption = {
|
|
||||||
label: string;
|
|
||||||
value: DownloadQuality;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ScreenOrientationEnum: Record<
|
|
||||||
ScreenOrientation.OrientationLock,
|
|
||||||
string
|
|
||||||
> = {
|
|
||||||
[ScreenOrientation.OrientationLock.DEFAULT]: "Default",
|
|
||||||
[ScreenOrientation.OrientationLock.ALL]: "All",
|
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait",
|
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up",
|
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down",
|
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape",
|
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left",
|
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right",
|
|
||||||
[ScreenOrientation.OrientationLock.OTHER]: "Other",
|
|
||||||
[ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadOptions: DownloadOption[] = [
|
|
||||||
{
|
|
||||||
label: "Original quality",
|
|
||||||
value: "original",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "High quality",
|
|
||||||
value: "high",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Small file size",
|
|
||||||
value: "low",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export type LibraryOptions = {
|
export type LibraryOptions = {
|
||||||
display: "row" | "list";
|
display: "row" | "list";
|
||||||
cardStyle: "compact" | "detailed";
|
cardStyle: "compact" | "detailed";
|
||||||
@@ -68,7 +29,6 @@ export type Settings = {
|
|||||||
searchEngine: "Marlin" | "Jellyfin";
|
searchEngine: "Marlin" | "Jellyfin";
|
||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
defaultAudioLanguage: CultureDto | null;
|
defaultAudioLanguage: CultureDto | null;
|
||||||
playDefaultAudioTrack: boolean;
|
playDefaultAudioTrack: boolean;
|
||||||
@@ -77,12 +37,9 @@ export type Settings = {
|
|||||||
subtitleMode: SubtitlePlaybackMode;
|
subtitleMode: SubtitlePlaybackMode;
|
||||||
rememberSubtitleSelections: boolean;
|
rememberSubtitleSelections: boolean;
|
||||||
showHomeTitles: boolean;
|
showHomeTitles: boolean;
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
|
||||||
forwardSkipTime: number;
|
forwardSkipTime: number;
|
||||||
rewindSkipTime: number;
|
rewindSkipTime: number;
|
||||||
optimizedVersionsServerUrl?: string | null;
|
optimizedVersionsServerUrl?: string | null;
|
||||||
downloadMethod: "optimized" | "remux";
|
|
||||||
autoDownload: boolean;
|
|
||||||
showCustomMenuLinks: boolean;
|
showCustomMenuLinks: boolean;
|
||||||
subtitleSize: number;
|
subtitleSize: number;
|
||||||
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
||||||
@@ -100,7 +57,6 @@ const loadSettings = (): Settings => {
|
|||||||
searchEngine: "Jellyfin",
|
searchEngine: "Jellyfin",
|
||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
openInVLC: false,
|
openInVLC: false,
|
||||||
downloadQuality: DownloadOptions[0],
|
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
display: "list",
|
display: "list",
|
||||||
cardStyle: "detailed",
|
cardStyle: "detailed",
|
||||||
@@ -115,12 +71,9 @@ const loadSettings = (): Settings => {
|
|||||||
subtitleMode: SubtitlePlaybackMode.Default,
|
subtitleMode: SubtitlePlaybackMode.Default,
|
||||||
rememberSubtitleSelections: true,
|
rememberSubtitleSelections: true,
|
||||||
showHomeTitles: true,
|
showHomeTitles: true,
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
|
||||||
forwardSkipTime: 30,
|
forwardSkipTime: 30,
|
||||||
rewindSkipTime: 10,
|
rewindSkipTime: 10,
|
||||||
optimizedVersionsServerUrl: null,
|
optimizedVersionsServerUrl: null,
|
||||||
downloadMethod: "remux",
|
|
||||||
autoDownload: false,
|
|
||||||
showCustomMenuLinks: false,
|
showCustomMenuLinks: false,
|
||||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||||
remuxConcurrentLimit: 1,
|
remuxConcurrentLimit: 1,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
|
|
||||||
export const BACKGROUND_FETCH_TASK = "background-fetch";
|
|
||||||
|
|
||||||
export async function registerBackgroundFetchAsync() {
|
|
||||||
try {
|
|
||||||
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
|
|
||||||
minimumInterval: 60 * 1, // 1 minutes
|
|
||||||
stopOnTerminate: false, // android only,
|
|
||||||
startOnBoot: false, // android only
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error registering background fetch task", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unregisterBackgroundFetchAsync() {
|
|
||||||
try {
|
|
||||||
BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error unregistering background fetch task", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import useImageStorage from "@/hooks/useImageStorage";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
const useDownloadHelper = () => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const { saveImage } = useImageStorage();
|
|
||||||
|
|
||||||
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
|
|
||||||
console.log(`Attempting to save primary image for item: ${item.Id}`);
|
|
||||||
if (
|
|
||||||
item.Type === "Episode" &&
|
|
||||||
item.SeriesId &&
|
|
||||||
!storage.getString(item.SeriesId)
|
|
||||||
) {
|
|
||||||
console.log(`Saving primary image for series: ${item.SeriesId}`);
|
|
||||||
await saveImage(
|
|
||||||
item.SeriesId,
|
|
||||||
getPrimaryImageUrlById({ api, id: item.SeriesId })
|
|
||||||
);
|
|
||||||
console.log(`Primary image saved for series: ${item.SeriesId}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Skipping primary image save for item: ${item.Id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { saveSeriesPrimaryImage };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDownloadHelper;
|
|
||||||
@@ -19,10 +19,9 @@ const mmkvStorage = createJSONStorage(() => ({
|
|||||||
}));
|
}));
|
||||||
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
||||||
|
|
||||||
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(null);
|
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(
|
||||||
const DownloadContext = createContext<ReturnType<
|
null
|
||||||
typeof useLogProvider
|
);
|
||||||
> | null>(null);
|
|
||||||
|
|
||||||
function useLogProvider() {
|
function useLogProvider() {
|
||||||
const { data: logs } = useQuery({
|
const { data: logs } = useQuery({
|
||||||
@@ -32,10 +31,9 @@ function useLogProvider() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logs
|
logs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
||||||
const newEntry: LogEntry = {
|
const newEntry: LogEntry = {
|
||||||
@@ -55,8 +53,10 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
|||||||
storage.set("logs", JSON.stringify(recentLogs));
|
storage.set("logs", JSON.stringify(recentLogs));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
|
export const writeInfoLog = (message: string, data?: any) =>
|
||||||
export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
|
writeToLog("INFO", message, data);
|
||||||
|
export const writeErrorLog = (message: string, data?: any) =>
|
||||||
|
writeToLog("ERROR", message, data);
|
||||||
|
|
||||||
export const readFromLog = (): LogEntry[] => {
|
export const readFromLog = (): LogEntry[] => {
|
||||||
const logs = storage.getString("logs");
|
const logs = storage.getString("logs");
|
||||||
@@ -78,11 +78,7 @@ export function useLog() {
|
|||||||
export function LogProvider({ children }: { children: React.ReactNode }) {
|
export function LogProvider({ children }: { children: React.ReactNode }) {
|
||||||
const provider = useLogProvider();
|
const provider = useLogProvider();
|
||||||
|
|
||||||
return (
|
return <LogContext.Provider value={provider}>{children}</LogContext.Provider>;
|
||||||
<LogContext.Provider value={provider}>
|
|
||||||
{children}
|
|
||||||
</LogContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default logsAtom;
|
export default logsAtom;
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import axios from "axios";
|
|
||||||
import { writeToLog } from "./log";
|
|
||||||
import { DownloadedItem } from "@/providers/DownloadProvider";
|
|
||||||
import { MMKV } from "react-native-mmkv";
|
|
||||||
|
|
||||||
interface IJobInput {
|
|
||||||
deviceId?: string | null;
|
|
||||||
authHeader?: string | null;
|
|
||||||
url?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobStatus {
|
|
||||||
id: string;
|
|
||||||
status:
|
|
||||||
| "queued"
|
|
||||||
| "optimizing"
|
|
||||||
| "completed"
|
|
||||||
| "failed"
|
|
||||||
| "cancelled"
|
|
||||||
| "downloading";
|
|
||||||
progress: number;
|
|
||||||
outputPath: string;
|
|
||||||
inputUrl: string;
|
|
||||||
deviceId: string;
|
|
||||||
itemId: string;
|
|
||||||
item: BaseItemDto;
|
|
||||||
speed?: number;
|
|
||||||
timestamp: Date;
|
|
||||||
base64Image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all jobs for a specific device.
|
|
||||||
*
|
|
||||||
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
|
|
||||||
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
|
|
||||||
* @param {string} params.authHeader - The authorization header for the API request.
|
|
||||||
* @param {string} params.url - The base URL for the API endpoint.
|
|
||||||
*
|
|
||||||
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
|
|
||||||
*
|
|
||||||
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
|
|
||||||
*/
|
|
||||||
export async function getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
}: IJobInput): Promise<JobStatus[]> {
|
|
||||||
const statusResponse = await axios.get(`${url}all-jobs`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
deviceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (statusResponse.status !== 200) {
|
|
||||||
console.error(
|
|
||||||
statusResponse.status,
|
|
||||||
statusResponse.data,
|
|
||||||
statusResponse.statusText
|
|
||||||
);
|
|
||||||
throw new Error("Failed to fetch job status");
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusResponse.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ICancelJob {
|
|
||||||
authHeader: string;
|
|
||||||
url: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelJobById({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
id,
|
|
||||||
}: ICancelJob): Promise<boolean> {
|
|
||||||
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (statusResponse.status !== 200) {
|
|
||||||
throw new Error("Failed to cancel process");
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
|
||||||
if (!deviceId) return false;
|
|
||||||
if (!authHeader) return false;
|
|
||||||
if (!url) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
}).then((jobs) => {
|
|
||||||
jobs.forEach((job) => {
|
|
||||||
cancelJobById({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
id: job.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Failed to cancel all jobs", error);
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches statistics for a specific device.
|
|
||||||
*
|
|
||||||
* @param {IJobInput} params - The parameters for the API request.
|
|
||||||
* @param {string} params.deviceId - The ID of the device to fetch statistics for.
|
|
||||||
* @param {string} params.authHeader - The authorization header for the API request.
|
|
||||||
* @param {string} params.url - The base URL for the API endpoint.
|
|
||||||
*
|
|
||||||
* @returns {Promise<any | null>} A promise that resolves to the statistics data or null if the request fails.
|
|
||||||
*
|
|
||||||
* @throws {Error} Throws an error if any required parameter is missing.
|
|
||||||
*/
|
|
||||||
export async function getStatistics({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
deviceId,
|
|
||||||
}: IJobInput): Promise<any | null> {
|
|
||||||
if (!deviceId || !authHeader || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusResponse = await axios.get(`${url}statistics`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
deviceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return statusResponse.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch statistics:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the download item info to disk - this data is used temporarily to fetch additional download information
|
|
||||||
* in combination with the optimize server. This is used to not have to send all item info to the optimize server.
|
|
||||||
*
|
|
||||||
* @param {BaseItemDto} item - The item to save.
|
|
||||||
* @param {MediaSourceInfo} mediaSource - The media source of the item.
|
|
||||||
* @param {string} url - The URL of the item.
|
|
||||||
* @return {boolean} A promise that resolves when the item info is saved.
|
|
||||||
*/
|
|
||||||
export function saveDownloadItemInfoToDiskTmp(
|
|
||||||
item: BaseItemDto,
|
|
||||||
mediaSource: MediaSourceInfo,
|
|
||||||
url: string
|
|
||||||
): boolean {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
|
|
||||||
const downloadInfo = JSON.stringify({
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
storage.set(`tmp_download_info_${item.Id}`, downloadInfo);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save download item info to disk:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the download item info from disk.
|
|
||||||
*
|
|
||||||
* @param {string} itemId - The ID of the item to retrieve.
|
|
||||||
* @return {{
|
|
||||||
* item: BaseItemDto;
|
|
||||||
* mediaSource: MediaSourceInfo;
|
|
||||||
* url: string;
|
|
||||||
* } | null} The retrieved download item info or null if not found.
|
|
||||||
*/
|
|
||||||
export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo;
|
|
||||||
url: string;
|
|
||||||
} | null {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
|
||||||
|
|
||||||
if (rawInfo) {
|
|
||||||
return JSON.parse(rawInfo);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve download item info from disk:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the download item info from disk.
|
|
||||||
*
|
|
||||||
* @param {string} itemId - The ID of the item to delete.
|
|
||||||
* @return {boolean} True if the item info was successfully deleted, false otherwise.
|
|
||||||
*/
|
|
||||||
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
storage.delete(`tmp_download_info_${itemId}`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete download item info from disk:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
|
|
||||||
export const chromecastProfile: DeviceProfile = {
|
|
||||||
Name: "Chromecast Video Profile",
|
|
||||||
MaxStreamingBitrate: 8000000, // 8 Mbps
|
|
||||||
MaxStaticBitrate: 8000000, // 8 Mbps
|
|
||||||
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
|
|
||||||
CodecProfiles: [
|
|
||||||
{
|
|
||||||
Type: "Video",
|
|
||||||
Codec: "h264",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: "Audio",
|
|
||||||
Codec: "aac,mp3,flac,opus,vorbis",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
DirectPlayProfiles: [
|
|
||||||
{
|
|
||||||
Container: "mp4",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac,mp3,opus,vorbis",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "flac",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "wav",
|
|
||||||
Type: "Audio",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
TranscodingProfiles: [
|
|
||||||
{
|
|
||||||
Container: "ts",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac,mp3",
|
|
||||||
Protocol: "hls",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
MinSegments: 2,
|
|
||||||
BreakOnNonKeyFrames: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp4",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "mp3",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "mp3",
|
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Container: "aac",
|
|
||||||
Type: "Audio",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "http",
|
|
||||||
Context: "Streaming",
|
|
||||||
MaxAudioChannels: "2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
SubtitleProfiles: [
|
|
||||||
{
|
|
||||||
Format: "vtt",
|
|
||||||
Method: "Encode",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Format: "vtt",
|
|
||||||
Method: "Encode",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user