mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-20 07:44:42 +01:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa785b0f95 | ||
|
|
8ea38a3efc | ||
|
|
07ad905e16 | ||
|
|
3058b866c3 | ||
|
|
497a1adc26 | ||
|
|
093cd94455 | ||
|
|
349a86bcfb | ||
|
|
4b81dff0be |
18
.github/workflows/notification.yaml
vendored
18
.github/workflows/notification.yaml
vendored
@@ -1,18 +0,0 @@
|
|||||||
name: Discord Pull Request Notification
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
notify:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: joelwmale/webhook-action@master
|
|
||||||
with:
|
|
||||||
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
body: |
|
|
||||||
{
|
|
||||||
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
|||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
If you have questions or need support, feel free to reach out:
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ If you have questions or need support, feel free to reach out:
|
|||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
@@ -130,4 +130,4 @@ I'd like to thank the following people and projects for their contributions to S
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
||||||
|
|||||||
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,29 +60,9 @@
|
|||||||
"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,75 +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/list/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,27 +0,0 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { Stack } from "expo-router";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
export default function SearchLayout() {
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Stack.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
headerLargeTitle: true,
|
|
||||||
headerTitle: "Favorites",
|
|
||||||
headerLargeStyle: {
|
|
||||||
backgroundColor: "black",
|
|
||||||
},
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Favorites } from "@/components/home/Favorites";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import React, { useCallback, useState } from "react";
|
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function favorites() {
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="my-4">
|
|
||||||
<Favorites />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
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";
|
||||||
@@ -16,14 +14,10 @@ export default function IndexLayout() {
|
|||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Home",
|
headerTitle: "Home",
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerLargeStyle: {
|
|
||||||
backgroundColor: "black",
|
|
||||||
},
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
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");
|
||||||
@@ -35,48 +29,12 @@ 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={{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="settings/optimized-server/page"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="settings/marlin-search/page"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="settings/jellyseerr/page"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="settings/popular-lists/page"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,250 +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 active 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().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">
|
||||||
|
|||||||
@@ -1,87 +1,106 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
import { clearLogs, useLog } from "@/utils/log";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { useAtom } from "jotai";
|
||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import * as Progress from "react-native-progress";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
|
||||||
import { clearLogs } from "@/utils/log";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
|
const { logs } = useLog();
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
const [api] = useAtom(apiAtom);
|
||||||
clearLogs();
|
const [user] = useAtom(userAtom);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
|
Alert.prompt(
|
||||||
|
"Quick connect",
|
||||||
|
"Enter the quick connect code",
|
||||||
|
async (text) => {
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
|
code: text,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
Alert.alert("Success", "Quick connect authorized");
|
||||||
|
} else {
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
logout();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-red-600">Log out</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<UserInfo />
|
<View>
|
||||||
<QuickConnect className="mb-4" />
|
<Text className="font-bold text-lg mb-2">User Info</Text>
|
||||||
|
|
||||||
<MediaProvider>
|
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
<MediaToggles className="mb-4" />
|
<ListItem title="User" subTitle={user?.Name} />
|
||||||
<AudioToggles className="mb-4" />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
<SubtitleToggles className="mb-4" />
|
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||||
</MediaProvider>
|
</View>
|
||||||
|
<Button className="my-2.5" color="black" onPress={logout}>
|
||||||
<OtherSettings />
|
Log out
|
||||||
<DownloadSettings />
|
</Button>
|
||||||
|
|
||||||
<PluginSettings />
|
|
||||||
|
|
||||||
<View className="mb-4">
|
|
||||||
<ListGroup title={"Logs"}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
|
||||||
showArrow
|
|
||||||
title={"Logs"}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
textColor="red"
|
|
||||||
onPress={onClearLogsClicked}
|
|
||||||
title={"Delete All Logs"}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<StorageSettings />
|
<View>
|
||||||
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
|
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
||||||
|
Authorize
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SettingToggles />
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{logs?.map((log, index) => (
|
||||||
|
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
mb-1
|
||||||
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{log.level}
|
||||||
|
</Text>
|
||||||
|
<Text uiTextView selectable className="text-xs">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{logs?.length === 0 && (
|
||||||
|
<Text className="opacity-50">No logs available</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async (newVal: string) => {
|
|
||||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
|
||||||
toast.error("Invalid URL");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl: updatedUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data) {
|
|
||||||
toast.success("Connected");
|
|
||||||
} else {
|
|
||||||
toast.error("Could not connect");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Could not connect");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSave = (newVal: string) => {
|
|
||||||
saveMutation.mutate(newVal);
|
|
||||||
};
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// navigation.setOptions({
|
|
||||||
// title: "Optimized Server",
|
|
||||||
// headerRight: () =>
|
|
||||||
// saveMutation.isPending ? (
|
|
||||||
// <ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
// ) : (
|
|
||||||
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
|
||||||
// <Text className="text-blue-500">Save</Text>
|
|
||||||
// </TouchableOpacity>
|
|
||||||
// ),
|
|
||||||
// });
|
|
||||||
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="p-4">
|
|
||||||
<JellyseerrSettings />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useLog } from "@/utils/log";
|
|
||||||
import { ScrollView, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { logs } = useLog();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView className="p-4">
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
{logs?.map((log, index) => (
|
|
||||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
|
||||||
<Text
|
|
||||||
className={`
|
|
||||||
mb-1
|
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{logs?.length === 0 && (
|
|
||||||
<Text className="opacity-50">No logs available</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
|
||||||
updateSettings({
|
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
|
||||||
});
|
|
||||||
toast.success("Saved");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity onPress={() => onSave(value)}>
|
|
||||||
<Text className="text-blue-500">Save</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation, value]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="px-4">
|
|
||||||
<ListGroup>
|
|
||||||
<ListItem
|
|
||||||
title={"Enable Marlin Search"}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.searchEngine === "Marlin"}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<View
|
|
||||||
className={`mt-2 ${
|
|
||||||
settings.searchEngine === "Marlin" ? "" : "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
|
||||||
<View
|
|
||||||
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
|
||||||
>
|
|
||||||
<Text className="mr-4">URL</Text>
|
|
||||||
<TextInput
|
|
||||||
editable={settings.searchEngine === "Marlin"}
|
|
||||||
className="text-white"
|
|
||||||
placeholder="http(s)://domain.org:port"
|
|
||||||
value={value}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => setValue(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
|
||||||
Enter the URL for the Marlin server. The URL should include http or
|
|
||||||
https and optionally the port.{" "}
|
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
|
||||||
Read more about Marlin.
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async (newVal: string) => {
|
|
||||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
|
||||||
toast.error("Invalid URL");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl: updatedUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data) {
|
|
||||||
toast.success("Connected");
|
|
||||||
} else {
|
|
||||||
toast.error("Could not connect");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Could not connect");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSave = (newVal: string) => {
|
|
||||||
saveMutation.mutate(newVal);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: "Optimized Server",
|
|
||||||
headerRight: () =>
|
|
||||||
saveMutation.isPending ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
|
||||||
<Text className="text-blue-500">Save</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="p-4">
|
|
||||||
<OptimizedServerForm
|
|
||||||
value={optimizedVersionsServerUrl}
|
|
||||||
onChangeValue={setOptimizedVersionsServerUrl}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { Linking, Switch, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL(
|
|
||||||
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mediaListCollections,
|
|
||||||
isLoading: isLoadingMediaListCollections,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
tags: ["sf_promoted"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["Tags"],
|
|
||||||
includeItemTypes: ["BoxSet"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="px-4 pt-4">
|
|
||||||
<ListGroup title={"Enable plugin"} className="">
|
|
||||||
<ListItem
|
|
||||||
title={"Enable Popular Lists"}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ usePopularPlugin: true });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.usePopularPlugin}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({ usePopularPlugin: value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
|
||||||
Popular Lists is a plugin that enables you to show custom Jellyfin lists
|
|
||||||
on the Streamyfin home page.{" "}
|
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
|
||||||
Read more about Popular Lists.
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{settings.usePopularPlugin && (
|
|
||||||
<>
|
|
||||||
{!isLoadingMediaListCollections ? (
|
|
||||||
<>
|
|
||||||
{mediaListCollections?.length === 0 ? (
|
|
||||||
<Text className="text-xs opacity-50 p-4">
|
|
||||||
No collections found. Add some in Jellyfin.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ListGroup title="Media List Collections" className="mt-4">
|
|
||||||
{mediaListCollections?.map((mlc) => (
|
|
||||||
<ListItem key={mlc.Id} title={mlc.Name}>
|
|
||||||
<Switch
|
|
||||||
value={settings.mediaListCollectionIds?.includes(
|
|
||||||
mlc.Id!
|
|
||||||
)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!settings.mediaListCollectionIds) {
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds: [mlc.Id!],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds:
|
|
||||||
settings.mediaListCollectionIds.includes(
|
|
||||||
mlc.Id!
|
|
||||||
)
|
|
||||||
? settings.mediaListCollectionIds.filter(
|
|
||||||
(id) => id !== mlc.Id
|
|
||||||
)
|
|
||||||
: [
|
|
||||||
...settings.mediaListCollectionIds,
|
|
||||||
mlc.Id!,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</ListGroup>
|
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
|
||||||
Select the lists you want displayed on the home screen.
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Loader />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,32 +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 {
|
import { Input } from "@/components/common/Input";
|
||||||
BottomSheetBackdrop,
|
import { Text } from "@/components/common/Text";
|
||||||
BottomSheetBackdropProps,
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
BottomSheetModal, BottomSheetTextInput,
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
BottomSheetView,
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
} from "@gorhom/bottom-sheet";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import {
|
import {
|
||||||
IssueType,
|
IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
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 { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -50,12 +50,12 @@ 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,
|
||||||
isFetching,
|
isFetching,
|
||||||
isLoading,
|
isLoading,
|
||||||
refetch
|
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
@@ -64,7 +64,6 @@ const Page: React.FC = () => {
|
|||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
refetchInterval: 0,
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return result.mediaType === MediaType.MOVIE
|
return result.mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!!)
|
? jellyseerrApi?.movieDetails(result.id!!)
|
||||||
@@ -96,18 +95,15 @@ const Page: React.FC = () => {
|
|||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const request = useCallback(
|
const request = useCallback(
|
||||||
async () => {
|
() =>
|
||||||
requestMedia(mediaTitle, {
|
requestMedia(mediaTitle, {
|
||||||
mediaId: Number(result.id!!),
|
mediaId: Number(result.id!!),
|
||||||
mediaType: result.mediaType!!,
|
mediaType: result.mediaType!!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.map?.((s) => s.seasonNumber),
|
||||||
},
|
}),
|
||||||
refetch
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[details, result, requestMedia]
|
[details, result, requestMedia]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -210,7 +206,6 @@ const Page: React.FC = () => {
|
|||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
result={result as TvResult}
|
result={result as TvResult}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -236,65 +231,81 @@ 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>
|
<View className="flex flex-col">
|
||||||
<DropdownMenu.Trigger>
|
<Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
|
||||||
<View className="flex flex-col">
|
<TouchableOpacity
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
Issue Type
|
onPress={() => setIsIssueTypeModalVisible(true)}
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: "Select an issue"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
|
||||||
side="bottom"
|
|
||||||
align="center"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Types</DropdownMenu.Label>
|
<Text className="" numberOfLines={1}>
|
||||||
{Object.entries(IssueTypeName)
|
{issueType ? IssueTypeName[issueType] : "Select an issue"}
|
||||||
.reverse()
|
</Text>
|
||||||
.map(([key, value], idx) => (
|
<Ionicons
|
||||||
<DropdownMenu.Item
|
name="chevron-down"
|
||||||
key={value}
|
size={16}
|
||||||
onSelect={() =>
|
color="white"
|
||||||
setIssueType(key as unknown as IssueType)
|
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)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<Text className="text-center text-purple-400">
|
||||||
{value}
|
Cancel
|
||||||
</DropdownMenu.ItemTitle>
|
</Text>
|
||||||
</DropdownMenu.Item>
|
</TouchableOpacity>
|
||||||
))}
|
</View>
|
||||||
</DropdownMenu.Content>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Root>
|
</Modal>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<Input
|
||||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full"
|
className="w-full"
|
||||||
>
|
placeholder="(optional) Describe the issue..."
|
||||||
<BottomSheetTextInput
|
value={issueMessage}
|
||||||
multiline
|
keyboardType="default"
|
||||||
maxLength={254}
|
returnKeyType="done"
|
||||||
style={{color: "white"}}
|
autoCapitalize="none"
|
||||||
clearButtonMode="always"
|
textContentType="none"
|
||||||
placeholder="(optional) Describe the issue..."
|
maxLength={254}
|
||||||
placeholderTextColor="#9CA3AF"
|
onChangeText={setIssueMessage}
|
||||||
// Issue with multiline + Textinput inside a portal
|
/>
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
|
||||||
defaultValue={issueMessage}
|
|
||||||
onChangeText={setIssueMessage}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||||
Submit
|
Submit
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
@@ -36,6 +34,7 @@ const page: React.FC = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: seriesId,
|
itemId: seriesId,
|
||||||
}),
|
}),
|
||||||
|
enabled: !!seriesId && !!api,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,35 +73,6 @@ const page: React.FC = () => {
|
|||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () =>
|
|
||||||
!isLoading &&
|
|
||||||
item &&
|
|
||||||
allEpisodes &&
|
|
||||||
allEpisodes.length > 0 && (
|
|
||||||
<View className="flex flex-row items-center space-x-2">
|
|
||||||
<AddToFavorites item={item} type="series" />
|
|
||||||
<DownloadItems
|
|
||||||
size="large"
|
|
||||||
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, item]);
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -41,6 +39,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -60,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) {
|
||||||
@@ -150,8 +147,6 @@ const Page = () => {
|
|||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
itemType = "BoxSet";
|
itemType = "BoxSet";
|
||||||
} else if (library.CollectionType === "music") {
|
|
||||||
itemType = "MusicAlbum";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -242,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;
|
||||||
|
|
||||||
@@ -19,169 +51,170 @@ export default function IndexLayout() {
|
|||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Library",
|
headerTitle: "Library",
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerLargeStyle: {
|
|
||||||
backgroundColor: "black",
|
|
||||||
},
|
|
||||||
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);
|
||||||
</DropdownMenu.Trigger>
|
}}
|
||||||
<DropdownMenu.Content
|
>
|
||||||
align={"end"}
|
<TouchableOpacity
|
||||||
alignOffset={-10}
|
className="flex-1 bg-black/50"
|
||||||
avoidCollisions={false}
|
activeOpacity={1}
|
||||||
collisionPadding={0}
|
onPress={() => {
|
||||||
loop={false}
|
setIsMenuVisible(false);
|
||||||
side={"bottom"}
|
setActiveSubmenu(null);
|
||||||
sideOffset={10}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
<DropdownMenu.Group key="display-group">
|
{!activeSubmenu ? (
|
||||||
<DropdownMenu.Sub>
|
<>
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
<MenuSection title="Display" />
|
||||||
Display
|
<MenuItem
|
||||||
</DropdownMenu.SubTrigger>
|
label="Display"
|
||||||
<DropdownMenu.SubContent
|
onPress={() => setActiveSubmenu("display")}
|
||||||
alignOffset={-10}
|
/>
|
||||||
avoidCollisions={true}
|
<MenuItem
|
||||||
collisionPadding={0}
|
label="Image style"
|
||||||
loop={true}
|
onPress={() => setActiveSubmenu("imageStyle")}
|
||||||
sideOffset={10}
|
/>
|
||||||
>
|
<MenuItem
|
||||||
<DropdownMenu.CheckboxItem
|
label="Show titles"
|
||||||
key="display-option-1"
|
selected={settings.libraryOptions.showTitles}
|
||||||
value={settings.libraryOptions.display === "row"}
|
disabled={
|
||||||
onValueChange={() =>
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: !settings.libraryOptions.showTitles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Show stats"
|
||||||
|
selected={settings.libraryOptions.showStats}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: !settings.libraryOptions.showStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : activeSubmenu === "display" ? (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setActiveSubmenu(null)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Display</Text>
|
||||||
|
</View>
|
||||||
|
<MenuItem
|
||||||
|
label="Row"
|
||||||
|
selected={settings.libraryOptions.display === "row"}
|
||||||
|
onPress={() => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
display: "row",
|
display: "row",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
setActiveSubmenu(null);
|
||||||
>
|
}}
|
||||||
<DropdownMenu.ItemIndicator />
|
/>
|
||||||
<DropdownMenu.ItemTitle key="display-title-1">
|
<MenuItem
|
||||||
Row
|
label="List"
|
||||||
</DropdownMenu.ItemTitle>
|
selected={settings.libraryOptions.display === "list"}
|
||||||
</DropdownMenu.CheckboxItem>
|
onPress={() => {
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="display-option-2"
|
|
||||||
value={settings.libraryOptions.display === "list"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
display: "list",
|
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={() => {
|
||||||
<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({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
imageStyle: "poster",
|
imageStyle: "poster",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Cover"
|
||||||
|
selected={
|
||||||
|
settings.libraryOptions.imageStyle === "cover"
|
||||||
}
|
}
|
||||||
>
|
onPress={() => {
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
|
||||||
Poster
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="cover-option"
|
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
imageStyle: "cover",
|
imageStyle: "cover",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
setActiveSubmenu(null);
|
||||||
>
|
}}
|
||||||
<DropdownMenu.ItemIndicator />
|
/>
|
||||||
<DropdownMenu.ItemTitle key="cover-title">
|
</>
|
||||||
Cover
|
) : null}
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Group key="show-titles-group">
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
|
||||||
key="show-titles-option"
|
|
||||||
value={settings.libraryOptions.showTitles}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
|
||||||
return;
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showTitles: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
|
||||||
Show titles
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="show-stats-option"
|
|
||||||
value={settings.libraryOptions.showStats}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showStats: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
|
||||||
Show stats
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
|
|
||||||
<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>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
||||||
commonScreenOptions,
|
|
||||||
nestedTabPageScreenOptions,
|
|
||||||
} from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
@@ -14,9 +11,6 @@ export default function SearchLayout() {
|
|||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Search",
|
headerTitle: "Search",
|
||||||
headerLargeStyle: {
|
|
||||||
backgroundColor: "black",
|
|
||||||
},
|
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -35,7 +29,10 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
<Stack.Screen
|
||||||
|
name="jellyseerr/page"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export default function search() {
|
|||||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||||
} else {
|
} else {
|
||||||
if (!settings?.marlinServerUrl) return [];
|
if (!settings?.marlinServerUrl) return [];
|
||||||
|
|
||||||
const url = `${
|
const url = `${
|
||||||
settings.marlinServerUrl
|
settings.marlinServerUrl
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
@@ -103,7 +102,6 @@ export default function search() {
|
|||||||
.join("&includeItemTypes=")}`;
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url);
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
if (!ids || !ids.length) return [];
|
||||||
@@ -320,7 +318,7 @@ export default function search() {
|
|||||||
text="Library"
|
text="Library"
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
searchType === "Library" ? "bg-neutral-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -329,7 +327,7 @@ export default function search() {
|
|||||||
text="Discover"
|
text="Discover"
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
searchType === "Discover" ? "bg-neutral-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -515,7 +513,7 @@ export default function search() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col px-4">
|
||||||
{sortBy?.(
|
{sortBy?.(
|
||||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||||
"order"
|
"order"
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/house.fill.png")
|
require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: () => ({ sfSymbol: "house" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "house.fill" }
|
|
||||||
: { sfSymbol: "house" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -62,26 +59,7 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: () => ({ sfSymbol: "magnifyingglass" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "magnifyingglass" }
|
|
||||||
: { sfSymbol: "magnifyingglass" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NativeTabs.Screen
|
|
||||||
name="(favorites)"
|
|
||||||
options={{
|
|
||||||
title: "Favorites",
|
|
||||||
tabBarIcon:
|
|
||||||
Platform.OS == "android"
|
|
||||||
? ({ color, focused, size }) =>
|
|
||||||
focused
|
|
||||||
? require("@/assets/icons/heart.fill.png")
|
|
||||||
: require("@/assets/icons/heart.png")
|
|
||||||
: ({ focused }) =>
|
|
||||||
focused
|
|
||||||
? { sfSymbol: "heart.fill" }
|
|
||||||
: { sfSymbol: "heart" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -92,25 +70,7 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/server.rack.png")
|
require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: () => ({ sfSymbol: "rectangle.stack" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
|
||||||
: { sfSymbol: "rectangle.stack" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NativeTabs.Screen
|
|
||||||
name="(custom-links)"
|
|
||||||
options={{
|
|
||||||
title: "Custom Links",
|
|
||||||
// @ts-expect-error
|
|
||||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
|
||||||
tabBarIcon:
|
|
||||||
Platform.OS == "android"
|
|
||||||
? ({ focused }) => require("@/assets/icons/list.png")
|
|
||||||
: ({ focused }) =>
|
|
||||||
focused
|
|
||||||
? { sfSymbol: "list.dash.fill" }
|
|
||||||
: { 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,11 +86,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
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,
|
||||||
@@ -127,20 +104,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
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,
|
||||||
@@ -175,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,
|
||||||
@@ -193,9 +155,11 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -219,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({
|
||||||
@@ -246,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!,
|
||||||
@@ -272,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;
|
||||||
@@ -292,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) => {
|
||||||
@@ -324,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;
|
||||||
@@ -335,39 +286,11 @@ export default function page() {
|
|||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
return async () => {
|
return async () => {
|
||||||
stop();
|
stop();
|
||||||
|
console.log("Unmounted");
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const [appState, setAppState] = useState(AppState.currentState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
|
||||||
// Handle app coming to the foreground
|
|
||||||
} else if (nextAppState.match(/inactive|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}`];
|
||||||
@@ -488,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({
|
||||||
@@ -169,15 +165,18 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
|
console.log("play");
|
||||||
videoRef.current?.resume();
|
videoRef.current?.resume();
|
||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
|
console.log("play");
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
console.log("stop");
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
@@ -258,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({
|
||||||
@@ -260,6 +255,13 @@ const Player = () => {
|
|||||||
progress.value = ticks;
|
progress.value = ticks;
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"onProgress ~",
|
||||||
|
ticks,
|
||||||
|
isPlaying,
|
||||||
|
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||||
// TODO: since playable duration is always 0 then.
|
// TODO: since playable duration is always 0 then.
|
||||||
setIsBuffering(data.playableDuration === 0);
|
setIsBuffering(data.playableDuration === 0);
|
||||||
@@ -292,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<
|
||||||
@@ -332,7 +330,11 @@ const Player = () => {
|
|||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
// Most likely the subtitle is burned in.
|
||||||
if (embeddedTrackIndex === -1) return;
|
if (embeddedTrackIndex === -1) return;
|
||||||
|
console.log(
|
||||||
|
"Setting selected text track",
|
||||||
|
subtitleIndex,
|
||||||
|
embeddedTrackIndex
|
||||||
|
);
|
||||||
setSelectedTextTrack({
|
setSelectedTextTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: embeddedTrackIndex,
|
value: embeddedTrackIndex,
|
||||||
@@ -428,6 +430,7 @@ const Player = () => {
|
|||||||
setIsBuffering(e.isBuffering);
|
setIsBuffering(e.isBuffering);
|
||||||
}}
|
}}
|
||||||
onAudioTracks={(e) => {
|
onAudioTracks={(e) => {
|
||||||
|
console.log("onAudioTracks: ", e.audioTracks);
|
||||||
setAudioTracks(
|
setAudioTracks(
|
||||||
e.audioTracks.map((t) => ({
|
e.audioTracks.map((t) => ({
|
||||||
index: t.index,
|
index: t.index,
|
||||||
@@ -481,6 +484,7 @@ const Player = () => {
|
|||||||
}}
|
}}
|
||||||
getAudioTracks={getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
setAudioTrack={(i) => {
|
setAudioTrack={(i) => {
|
||||||
|
console.log("setAudioTrack ~", i);
|
||||||
setSelectedAudioTrack({
|
setSelectedAudioTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: i,
|
value: i,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
|||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useGlobalSearchParams();
|
const searchParams = useGlobalSearchParams();
|
||||||
|
console.log(searchParams);
|
||||||
|
|
||||||
const { url } = searchParams as { url: string };
|
const { url } = searchParams as { url: string };
|
||||||
|
|
||||||
|
|||||||
408
app/_layout.tsx
408
app/_layout.tsx
@@ -1,43 +1,19 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { Text } from "@/components/common/Text";
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
|
||||||
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, TouchableOpacity } 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";
|
||||||
@@ -45,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"),
|
||||||
@@ -246,159 +58,75 @@ 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>
|
<BottomSheetModalProvider>
|
||||||
<DownloadProvider>
|
<SystemBars style="light" hidden={false} />
|
||||||
<BottomSheetModalProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<SystemBars style="light" hidden={false} />
|
<Stack initialRouteName="/home">
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack.Screen
|
||||||
<Stack initialRouteName="/home">
|
name="(auth)/(tabs)"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="(auth)/(tabs)"
|
headerShown: false,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: false,
|
header: () => null,
|
||||||
title: "",
|
}}
|
||||||
header: () => null,
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name="(auth)/player"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="(auth)/player"
|
headerShown: false,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: false,
|
header: () => null,
|
||||||
title: "",
|
}}
|
||||||
header: () => null,
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name="(auth)/trailer/page"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="(auth)/trailer/page"
|
headerShown: false,
|
||||||
options={{
|
presentation: "modal",
|
||||||
headerShown: false,
|
title: "",
|
||||||
presentation: "modal",
|
}}
|
||||||
title: "",
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name="login"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="login"
|
headerShown: true,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: true,
|
headerTransparent: true,
|
||||||
title: "",
|
}}
|
||||||
headerTransparent: true,
|
/>
|
||||||
}}
|
<Stack.Screen name="+not-found" />
|
||||||
/>
|
</Stack>
|
||||||
<Stack.Screen name="+not-found" />
|
<Toaster
|
||||||
</Stack>
|
duration={4000}
|
||||||
<Toaster
|
toastOptions={{
|
||||||
duration={4000}
|
style: {
|
||||||
toastOptions={{
|
backgroundColor: "#262626",
|
||||||
style: {
|
borderColor: "#363639",
|
||||||
backgroundColor: "#262626",
|
borderWidth: 1,
|
||||||
borderColor: "#363639",
|
},
|
||||||
borderWidth: 1,
|
titleStyle: {
|
||||||
},
|
color: "white",
|
||||||
titleStyle: {
|
},
|
||||||
color: "white",
|
}}
|
||||||
},
|
closeButton
|
||||||
}}
|
/>
|
||||||
closeButton
|
</ThemeProvider>
|
||||||
/>
|
</BottomSheetModalProvider>
|
||||||
</ThemeProvider>
|
</WebSocketProvider>
|
||||||
</BottomSheetModalProvider>
|
</LogProvider>
|
||||||
</DownloadProvider>
|
</PlaySettingsProvider>
|
||||||
</WebSocketProvider>
|
</JellyfinProvider>
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
|
||||||
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";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -121,7 +119,7 @@ const Login: React.FC = () => {
|
|||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
const checkUrl = useCallback(async (url: string) => {
|
async function checkUrl(url: string) {
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +129,6 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
|
||||||
setServerName(data.ServerName || "");
|
setServerName(data.ServerName || "");
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -140,7 +137,7 @@ const Login: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
* Handles the connection attempt to a Jellyfin server.
|
||||||
@@ -158,7 +155,7 @@ const Login: React.FC = () => {
|
|||||||
* - Sets the server address using `setServer` if the connection is successful.
|
* - Sets the server address using `setServer` if the connection is successful.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const handleConnect = useCallback(async (url: string) => {
|
const handleConnect = async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim();
|
||||||
|
|
||||||
const result = await checkUrl(url);
|
const result = await checkUrl(url);
|
||||||
@@ -172,7 +169,7 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: url });
|
setServer({ address: url });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -210,7 +207,7 @@ const Login: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">{api.basePath}</Text>
|
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -296,14 +293,9 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Text className="text-xs text-neutral-500 ml-4">
|
<Text className="text-xs text-neutral-500">
|
||||||
Make sure to include http or https
|
Make sure to include http or https
|
||||||
</Text>
|
</Text>
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={(s) => {
|
|
||||||
handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,9 +1,9 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Number {
|
interface Number {
|
||||||
bytesToReadable(): string;
|
bytesToReadable(): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number
|
||||||
hoursToMilliseconds(): number;
|
hoursToMilliseconds(): number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,27 +11,27 @@ Number.prototype.bytesToReadable = function () {
|
|||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
const gb = bytes / 1e9;
|
const gb = bytes / 1e9;
|
||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(0)} GB`;
|
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
const mb = bytes / 1024.0 / 1024.0;
|
||||||
if (mb >= 1) return `${mb.toFixed(0)} MB`;
|
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
const kb = bytes / 1024.0;
|
||||||
if (kb >= 1) return `${kb.toFixed(0)} KB`;
|
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
return `${bytes.toFixed(2)} B`;
|
||||||
};
|
}
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
return this.valueOf() * 1000;
|
return this.valueOf() * 1000
|
||||||
};
|
}
|
||||||
|
|
||||||
Number.prototype.minutesToMilliseconds = function () {
|
Number.prototype.minutesToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds()
|
||||||
};
|
}
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).minutesToMilliseconds();
|
return this.valueOf() * (60).minutesToMilliseconds()
|
||||||
};
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
type: "item" | "series";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const isFavorite = useMemo(() => {
|
|
||||||
return item.UserData?.IsFavorite;
|
|
||||||
}, [item.UserData?.IsFavorite]);
|
|
||||||
|
|
||||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
|
||||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
|
||||||
[type, item.Id],
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
...newData,
|
|
||||||
UserData: { ...old.UserData, ...newData.UserData },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const markFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).markFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unmarkFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
size="large"
|
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
|
||||||
onPress={() => {
|
|
||||||
if (isFavorite) {
|
|
||||||
unmarkFavoriteMutation.mutate();
|
|
||||||
} else {
|
|
||||||
markFavoriteMutation.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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
|
<>
|
||||||
className="flex shrink"
|
<View
|
||||||
style={{
|
className="flex shrink"
|
||||||
minWidth: 50,
|
style={{
|
||||||
}}
|
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
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text className="" numberOfLines={1}>
|
onPress={() => setIsModalVisible(true)}
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
>
|
||||||
|
<Text className="" numberOfLines={1}>
|
||||||
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Audio Streams
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{audioStreams?.map((audio, idx: number) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={idx.toString()}
|
||||||
|
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between`}
|
||||||
|
onPress={() => {
|
||||||
|
if (audio.Index !== null && audio.Index !== undefined) {
|
||||||
|
onChange(audio.Index);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{audio.DisplayTitle}</Text>
|
||||||
|
{audio.Index === selected && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</TouchableOpacity>
|
</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>Audio streams</DropdownMenu.Label>
|
|
||||||
{audioStreams?.map((audio, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (audio.Index !== null && audio.Index !== undefined)
|
|
||||||
onChange(audio.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{audio.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,49 +59,81 @@ 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
|
<>
|
||||||
className="flex shrink"
|
<View
|
||||||
style={{
|
className="flex shrink"
|
||||||
minWidth: 60,
|
style={{
|
||||||
maxWidth: 200,
|
minWidth: 60,
|
||||||
}}
|
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
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
onPress={() => setIsModalVisible(true)}
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
>
|
||||||
|
<Text className="" numberOfLines={1}>
|
||||||
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Quality
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{sorted.map((bitrate) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={bitrate.key}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
onChange(bitrate);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{bitrate.key}</Text>
|
||||||
|
{bitrate.value === selected?.value && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
</Modal>
|
||||||
loop={false}
|
</>
|
||||||
side="bottom"
|
|
||||||
align="center"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
|
||||||
{sorted.map((b) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={b.key}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,86 +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) {
|
|
||||||
console.warn("DiscoveryManager is not initialized");
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
<AndroidCastButton />
|
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
|
||||||
</RoundButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoundButton
|
|
||||||
size="large"
|
|
||||||
onPress={() => {
|
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
|
||||||
else CastContext.showCastDialog();
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<AndroidCastButton />
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -26,7 +26,7 @@ export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-x
|
|||||||
return (
|
return (
|
||||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View key={idx}>
|
<View>
|
||||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,17 +21,14 @@ 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";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -47,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);
|
||||||
@@ -86,12 +78,9 @@ 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} />
|
||||||
<AddToFavorites item={item} type="item" />
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -100,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]);
|
||||||
|
|
||||||
|
|||||||
@@ -175,8 +175,6 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
) as MediaStream;
|
) as MediaStream;
|
||||||
}, [source.MediaStreams]);
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
if (!videoStream) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
35
components/ListItem.tsx
Normal file
35
components/ListItem.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
subTitle?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
subTitle,
|
||||||
|
iconAfter,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col overflow-visible">
|
||||||
|
<Text className="font-bold ">{title}</Text>
|
||||||
|
{subTitle && (
|
||||||
|
<Text uiTextView selectable className="text-xs text-neutral-400">
|
||||||
|
{subTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{iconAfter}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
|
<>
|
||||||
className="flex shrink"
|
<View
|
||||||
style={{
|
className="flex shrink"
|
||||||
minWidth: 50,
|
style={{
|
||||||
}}
|
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
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
onPress={() => setIsModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Media Sources
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{item.MediaSources?.map((source, idx: number) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={idx.toString()}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
onChange(source);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||||
|
source.Size
|
||||||
|
)}`}
|
||||||
|
</Text>
|
||||||
|
{source.Id === selected?.Id && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</TouchableOpacity>
|
</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>Media sources</DropdownMenu.Label>
|
|
||||||
{item.MediaSources?.map((source, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(source);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
|
||||||
source.Size
|
|
||||||
)}`}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
return;
|
||||||
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,21 +218,13 @@ 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}>
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<MaterialCommunityIcons
|
||||||
<Feather name="cast" size={22} />
|
name="vlc"
|
||||||
<CastButton tintColor="transparent" />
|
size={18}
|
||||||
</Animated.Text>
|
color={animatedTextStyle.color}
|
||||||
)}
|
/>
|
||||||
{!client && settings?.openInVLC && (
|
</Animated.Text>
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="vlc"
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useMMKVString } from "react-native-mmkv";
|
|
||||||
import { ListGroup } from "./list/ListGroup";
|
|
||||||
import { ListItem } from "./list/ListItem";
|
|
||||||
|
|
||||||
interface Server {
|
|
||||||
address: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviousServersListProps {
|
|
||||||
onServerSelect: (server: Server) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|
||||||
onServerSelect,
|
|
||||||
}) => {
|
|
||||||
const [_previousServers, setPreviousServers] =
|
|
||||||
useMMKVString("previousServers");
|
|
||||||
|
|
||||||
const previousServers = useMemo(() => {
|
|
||||||
return JSON.parse(_previousServers || "[]") as Server[];
|
|
||||||
}, [_previousServers]);
|
|
||||||
|
|
||||||
if (!previousServers.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<ListGroup title="previous servers" className="mt-4">
|
|
||||||
{previousServers.map((s) => (
|
|
||||||
<ListItem
|
|
||||||
key={s.address}
|
|
||||||
onPress={() => onServerSelect(s)}
|
|
||||||
title={s.address}
|
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<ListItem
|
|
||||||
onPress={() => {
|
|
||||||
setPreviousServers("[]");
|
|
||||||
}}
|
|
||||||
title={"Clear"}
|
|
||||||
textColor="red"
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -6,7 +6,6 @@ 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;
|
||||||
@@ -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,59 +40,98 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
className="flex col shrink justify-start place-self-start items-start"
|
<View
|
||||||
style={{
|
className="flex col shrink justify-start place-self-start items-start"
|
||||||
minWidth: 60,
|
style={{
|
||||||
maxWidth: 200,
|
minWidth: 60,
|
||||||
}}
|
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
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text className=" ">
|
onPress={() => setIsModalVisible(true)}
|
||||||
{selectedSubtitleSteam
|
>
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
<Text>
|
||||||
: "None"}
|
{selectedSubtitleSteam
|
||||||
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
|
: "None"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Subtitle Tracks
|
||||||
</Text>
|
</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>
|
</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>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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,11 +1,13 @@
|
|||||||
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 {
|
||||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
hasPermission,
|
||||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
result: MovieResult | TvResult;
|
result: MovieResult | TvResult;
|
||||||
@@ -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>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
// @ts-ignore
|
||||||
onPress={() => {
|
router.push({
|
||||||
// @ts-ignore
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
params: {
|
||||||
}}
|
...result,
|
||||||
{...props}
|
mediaTitle,
|
||||||
>
|
releaseYear,
|
||||||
{children}
|
canRequest,
|
||||||
</TouchableOpacity>
|
posterSrc,
|
||||||
</ContextMenu.Trigger>
|
},
|
||||||
<ContextMenu.Content
|
});
|
||||||
avoidCollisions
|
}}
|
||||||
alignOffset={0}
|
{...props}
|
||||||
collisionPadding={0}
|
>
|
||||||
loop={false}
|
{children}
|
||||||
key={"content"}
|
</TouchableOpacity>
|
||||||
>
|
|
||||||
<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,8 +1,5 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import {
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
BaseItemDto,
|
|
||||||
BaseItemPerson,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
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";
|
||||||
@@ -12,13 +9,8 @@ interface Props extends TouchableOpacityProps {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (
|
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||||
item: BaseItemDto | BaseItemPerson,
|
if (item.CollectionType === "livetv") {
|
||||||
from: string
|
|
||||||
) => {
|
|
||||||
console.log(item.Type, item?.CollectionType);
|
|
||||||
|
|
||||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
|
||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +30,7 @@ export const itemRouter = (
|
|||||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "Person" || item.Type === "Actor") {
|
if (item.Type === "Person") {
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,85 +65,17 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
if (
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
from === "(home)" ||
|
|
||||||
from === "(search)" ||
|
|
||||||
from === "(libraries)" ||
|
|
||||||
from === "(favorites)"
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
const url = itemRouter(item, from);
|
||||||
onPress={() => {
|
// @ts-ignore
|
||||||
const url = itemRouter(item, from);
|
router.push(url);
|
||||||
// @ts-ignore
|
}}
|
||||||
router.push(url);
|
{...props}
|
||||||
}}
|
>
|
||||||
{...props}
|
{children}
|
||||||
>
|
</TouchableOpacity>
|
||||||
{children}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
|
||||||
alignOffset={0}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={false}
|
|
||||||
key={"content"}
|
|
||||||
>
|
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
|
||||||
<ContextMenu.Item
|
|
||||||
key="item-1"
|
|
||||||
onSelect={() => {
|
|
||||||
markAsPlayedStatus(true);
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key="item-1-title">
|
|
||||||
Mark as watched
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "green", // Changed to green for "watched"
|
|
||||||
light: "green",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName="checkmark-circle"
|
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
key="item-2"
|
|
||||||
onSelect={() => {
|
|
||||||
markAsPlayedStatus(false);
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key="item-2-title">
|
|
||||||
Mark as not watched
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
|
||||||
pointSize: 18, // Adjusted for better visibility
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "red", // Changed to red for "not watched"
|
|
||||||
light: "red",
|
|
||||||
},
|
|
||||||
// Removed paletteColors as it's not necessary in this case
|
|
||||||
}}
|
|
||||||
androidIconName="eye-slash"
|
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
|
|
||||||
export const Favorites = () => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const fetchFavoritesByType = useCallback(
|
|
||||||
async (itemType: BaseItemKind) => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id!,
|
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
filters: ["IsFavorite"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
|
||||||
collapseBoxSetItems: false,
|
|
||||||
excludeLocationTypes: ["Virtual"],
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
limit: 20,
|
|
||||||
includeItemTypes: [itemType],
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
[api, user]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchFavoriteSeries = useCallback(
|
|
||||||
() => fetchFavoritesByType("Series"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteMovies = useCallback(
|
|
||||||
() => fetchFavoritesByType("Movie"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteEpisodes = useCallback(
|
|
||||||
() => fetchFavoritesByType("Episode"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteVideos = useCallback(
|
|
||||||
() => fetchFavoritesByType("Video"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteBoxsets = useCallback(
|
|
||||||
() => fetchFavoritesByType("BoxSet"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoritePlaylists = useCallback(
|
|
||||||
() => fetchFavoritesByType("Playlist"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteMusicAlbum = useCallback(
|
|
||||||
() => fetchFavoritesByType("MusicAlbum"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteAudio = useCallback(
|
|
||||||
() => fetchFavoritesByType("Audio"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-co gap-y-4">
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteSeries}
|
|
||||||
queryKey={["home", "favorites", "series"]}
|
|
||||||
title="Series"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteMovies}
|
|
||||||
queryKey={["home", "favorites", "movies"]}
|
|
||||||
title="Movies"
|
|
||||||
hideIfEmpty
|
|
||||||
orientation="vertical"
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteEpisodes}
|
|
||||||
queryKey={["home", "favorites", "episodes"]}
|
|
||||||
title="Episodes"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteVideos}
|
|
||||||
queryKey={["home", "favorites", "videos"]}
|
|
||||||
title="Videos"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteBoxsets}
|
|
||||||
queryKey={["home", "favorites", "boxsets"]}
|
|
||||||
title="Boxsets"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoritePlaylists}
|
|
||||||
queryKey={["home", "favorites", "playlists"]}
|
|
||||||
title="Playlists"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteMusicAlbum}
|
|
||||||
queryKey={["home", "favorites", "musicAlbums"]}
|
|
||||||
title="Music Albums"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteAudio}
|
|
||||||
queryKey={["home", "favorites", "audio"]}
|
|
||||||
title="Audio"
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 {}
|
||||||
|
|
||||||
@@ -84,27 +83,21 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const width = Dimensions.get("screen").width;
|
const width = Dimensions.get("screen").width;
|
||||||
|
|
||||||
if (settings?.usePopularPlugin === false) return null;
|
|
||||||
if (l1 || l2) return null;
|
if (l1 || l2) return null;
|
||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center mt-2" {...props}>
|
<View className="flex flex-col items-center" {...props}>
|
||||||
<Carousel
|
<Carousel
|
||||||
ref={ref}
|
autoPlay={true}
|
||||||
autoPlay={false}
|
autoPlayInterval={3000}
|
||||||
loop={true}
|
loop={true}
|
||||||
snapEnabled={true}
|
ref={ref}
|
||||||
mode="parallax"
|
|
||||||
modeConfig={{
|
|
||||||
parallaxScrollingScale: 0.86,
|
|
||||||
parallaxScrollingOffset: 100,
|
|
||||||
}}
|
|
||||||
width={width}
|
width={width}
|
||||||
height={204}
|
height={204}
|
||||||
data={popularItems}
|
data={popularItems}
|
||||||
onProgressChange={progress}
|
onProgressChange={progress}
|
||||||
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
renderItem={({ item, index }) => <RenderItem item={item} />}
|
||||||
/>
|
/>
|
||||||
<Pagination.Basic
|
<Pagination.Basic
|
||||||
progress={progress}
|
progress={progress}
|
||||||
@@ -153,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]);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface Props extends ViewProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
hideIfEmpty?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -27,9 +26,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
// console.log(queryKey);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
@@ -41,10 +41,8 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
if (hideIfEmpty === true && data?.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props} className="">
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -84,13 +82,15 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{data?.map((item) => (
|
{data?.map((item, index) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={index}
|
||||||
className={`mr-2
|
className={`
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
mr-2
|
||||||
`}
|
|
||||||
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
|
|||||||
@@ -71,14 +71,11 @@ const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
|||||||
flatData &&
|
flatData &&
|
||||||
flatData?.length > 0 && (
|
flatData?.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<Text className="font-bold text-lg mb-2 px-4">
|
<Text className="font-bold text-lg mb-2">
|
||||||
{DiscoverSliderType[slide.type].toString().toTitle()}
|
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||||
</Text>
|
</Text>
|
||||||
<FlashList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: 16,
|
|
||||||
}}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
keyExtractor={(item) => item!!.id.toString()}
|
keyExtractor={(item) => item!!.id.toString()}
|
||||||
estimatedItemSize={250}
|
estimatedItemSize={250}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
|
||||||
CollectionType,
|
CollectionType,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -51,52 +50,18 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
|
||||||
let _itemType: BaseItemKind | undefined;
|
|
||||||
|
|
||||||
if (library.CollectionType === "movies") {
|
|
||||||
_itemType = "Movie";
|
|
||||||
} else if (library.CollectionType === "tvshows") {
|
|
||||||
_itemType = "Series";
|
|
||||||
} else if (library.CollectionType === "boxsets") {
|
|
||||||
_itemType = "BoxSet";
|
|
||||||
} else if (library.CollectionType === "music") {
|
|
||||||
_itemType = "MusicAlbum";
|
|
||||||
}
|
|
||||||
|
|
||||||
return _itemType;
|
|
||||||
}, [library.CollectionType]);
|
|
||||||
|
|
||||||
const itemTypeName = useMemo(() => {
|
|
||||||
let nameStr: string;
|
|
||||||
|
|
||||||
if (library.CollectionType === "movies") {
|
|
||||||
nameStr = "movies";
|
|
||||||
} else if (library.CollectionType === "tvshows") {
|
|
||||||
nameStr = "series";
|
|
||||||
} else if (library.CollectionType === "boxsets") {
|
|
||||||
nameStr = "box sets";
|
|
||||||
} else if (library.CollectionType === "music") {
|
|
||||||
nameStr = "albums";
|
|
||||||
} else {
|
|
||||||
nameStr = "items";
|
|
||||||
}
|
|
||||||
|
|
||||||
return nameStr;
|
|
||||||
}, [library.CollectionType]);
|
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await getItemsApi(api!).getItems({
|
if (!api) return null;
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: library.Id,
|
parentId: library.Id,
|
||||||
recursive: true,
|
|
||||||
limit: 0,
|
limit: 0,
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
|
||||||
});
|
});
|
||||||
return response.data.TotalRecordCount;
|
return response.data.TotalRecordCount;
|
||||||
},
|
},
|
||||||
|
staleTime: 1000 * 60 * 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@@ -115,7 +80,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
||||||
{itemsCount} {itemTypeName}
|
{itemsCount} items
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -144,7 +109,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -164,7 +128,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
)}
|
)}
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-start px-4">
|
<Text className="font-bold text-xs text-start px-4">
|
||||||
{itemsCount} {itemTypeName}
|
{itemsCount} items
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -181,7 +145,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
||||||
{itemsCount} {itemTypeName}
|
{itemsCount} items
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import {
|
|
||||||
PropsWithChildren,
|
|
||||||
Children,
|
|
||||||
isValidElement,
|
|
||||||
cloneElement,
|
|
||||||
ReactElement,
|
|
||||||
} from "react";
|
|
||||||
import { StyleSheet, View, ViewProps, ViewStyle } from "react-native";
|
|
||||||
import { ListItem } from "./ListItem";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
title?: string | null | undefined;
|
|
||||||
description?: ReactElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
description,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const childrenArray = Children.toArray(children);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<Text className="ml-4 mb-1 uppercase text-[#8E8D91] text-xs">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={[]}
|
|
||||||
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
|
|
||||||
>
|
|
||||||
{Children.map(childrenArray, (child, index) => {
|
|
||||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
|
||||||
return cloneElement(child as any, {
|
|
||||||
style: StyleSheet.compose(
|
|
||||||
child.props.style,
|
|
||||||
index < childrenArray.length - 1
|
|
||||||
? styles.borderBottom
|
|
||||||
: undefined
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
{description && <View className="pl-4 mt-1">{description}</View>}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
borderBottom: {
|
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderBottomColor: "#3D3C40",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
TouchableOpacity,
|
|
||||||
TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps, ViewProps {
|
|
||||||
title?: string | null | undefined;
|
|
||||||
value?: string | null | undefined;
|
|
||||||
children?: ReactNode;
|
|
||||||
iconAfter?: ReactNode;
|
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
|
||||||
showArrow?: boolean;
|
|
||||||
textColor?: "default" | "blue" | "red";
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
iconAfter,
|
|
||||||
children,
|
|
||||||
showArrow = false,
|
|
||||||
icon,
|
|
||||||
textColor = "default",
|
|
||||||
onPress,
|
|
||||||
disabled = false,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
if (onPress)
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={onPress}
|
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
|
||||||
disabled ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ListItemContent
|
|
||||||
title={title}
|
|
||||||
value={value}
|
|
||||||
icon={icon}
|
|
||||||
textColor={textColor}
|
|
||||||
showArrow={showArrow}
|
|
||||||
iconAfter={iconAfter}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ListItemContent>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
|
||||||
disabled ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ListItemContent
|
|
||||||
title={title}
|
|
||||||
value={value}
|
|
||||||
icon={icon}
|
|
||||||
textColor={textColor}
|
|
||||||
showArrow={showArrow}
|
|
||||||
iconAfter={iconAfter}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ListItemContent>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ListItemContent = ({
|
|
||||||
title,
|
|
||||||
textColor,
|
|
||||||
icon,
|
|
||||||
value,
|
|
||||||
showArrow,
|
|
||||||
iconAfter,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: Props) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View className="flex flex-row items-center w-full">
|
|
||||||
{icon && (
|
|
||||||
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
|
|
||||||
<Ionicons name="person-circle-outline" size={18} color="white" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
className={
|
|
||||||
textColor === "blue"
|
|
||||||
? "text-[#0584FE]"
|
|
||||||
: textColor === "red"
|
|
||||||
? "text-red-600"
|
|
||||||
: "text-white"
|
|
||||||
}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{value && (
|
|
||||||
<View className="ml-auto items-end">
|
|
||||||
<Text selectable className=" text-[#9899A1]" numberOfLines={1}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{children && <View className="ml-auto">{children}</View>}
|
|
||||||
{showArrow && (
|
|
||||||
<View className={children ? "ml-1" : "ml-auto"}>
|
|
||||||
<Ionicons name="chevron-forward" size={18} color="#5A5960" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{iconAfter}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -61,7 +61,7 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
{collection.Name}
|
{collection.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<InfiniteHorizontalScroll
|
<InfiniteHorizontalScroll
|
||||||
|
|||||||
@@ -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") => {
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemPerson,
|
BaseItemPerson,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router, useSegments } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../posters/Poster";
|
import Poster from "../posters/Poster";
|
||||||
import { itemRouter } from "../common/TouchableItemRouter";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -20,8 +19,6 @@ interface Props extends ViewProps {
|
|||||||
|
|
||||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const segments = useSegments();
|
|
||||||
const from = segments[2];
|
|
||||||
|
|
||||||
const destinctPeople = useMemo(() => {
|
const destinctPeople = useMemo(() => {
|
||||||
const people: BaseItemPerson[] = [];
|
const people: BaseItemPerson[] = [];
|
||||||
@@ -36,8 +33,6 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
return people;
|
return people;
|
||||||
}, [item?.People]);
|
}, [item?.People]);
|
||||||
|
|
||||||
if (!from) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="flex flex-col">
|
<View {...props} className="flex flex-col">
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
@@ -49,9 +44,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const url = itemRouter(i, from);
|
router.push(`/actors/${i.Id}`);
|
||||||
// @ts-ignore
|
|
||||||
router.push(url);
|
|
||||||
}}
|
}}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
|
|
||||||
const JellyseerrSeasonEpisodes: React.FC<{
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
details: TvDetails;
|
details: TvDetails;
|
||||||
@@ -101,8 +100,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
result?: TvResult;
|
result?: TvResult;
|
||||||
details?: TvDetails;
|
details?: TvDetails;
|
||||||
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
}> = ({ isLoading, result, details }) => {
|
||||||
}> = ({ isLoading, result, details, refetch }) => {
|
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
|
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
@@ -170,21 +168,6 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
[requestAll]
|
[requestAll]
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
|
|
||||||
if (canRequest) {
|
|
||||||
requestMedia(
|
|
||||||
`${result?.name!!}, Season ${seasonNumber}`,
|
|
||||||
{
|
|
||||||
mediaId: details.id,
|
|
||||||
mediaType: MediaType.TV,
|
|
||||||
tvdbId: details.externalIds?.tvdbId,
|
|
||||||
seasons: [seasonNumber],
|
|
||||||
},
|
|
||||||
refetch
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [requestMedia]);
|
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -248,7 +231,22 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<JellyseerrIconStatus
|
<JellyseerrIconStatus
|
||||||
key={0}
|
key={0}
|
||||||
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
onPress={
|
||||||
|
canRequest
|
||||||
|
? () =>
|
||||||
|
requestMedia(
|
||||||
|
`${result?.name!!}, Season ${
|
||||||
|
season.seasonNumber
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
mediaId: details.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
|
seasons: [season.seasonNumber],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||||
mediaStatus={
|
mediaStatus={
|
||||||
seasons?.find(
|
seasons?.find(
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
<Text>Season {seasonIndex}</Text>
|
||||||
{seasons?.sort(sortByIndex).map((season: any) => (
|
<Ionicons
|
||||||
<DropdownMenu.Item
|
name="chevron-down"
|
||||||
key={season[keys.title]}
|
size={16}
|
||||||
onSelect={() => onSelect(season)}
|
color="white"
|
||||||
>
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
<DropdownMenu.ItemTitle>
|
/>
|
||||||
{season[keys.title]}
|
</TouchableOpacity>
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
<Modal
|
||||||
))}
|
visible={isModalVisible}
|
||||||
</DropdownMenu.Content>
|
transparent
|
||||||
</DropdownMenu.Root>
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Season
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={season[keys.title]}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(season);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{season[keys.title]}</Text>
|
||||||
|
{Number(season[keys.index]) === Number(seasonIndex) && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { 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,11 +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 { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -13,79 +11,137 @@ 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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View>
|
||||||
<ListGroup
|
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||||
title={"Audio"}
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
description={
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
<Text className="text-[#8E8D91] text-xs">
|
<View className="flex flex-col shrink">
|
||||||
Choose a default audio language.
|
<Text className="font-semibold">Audio language</Text>
|
||||||
</Text>
|
<Text className="text-xs opacity-50">
|
||||||
}
|
Choose a default audio language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
|
onPress={() => setIsModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text>{settings?.defaultAudioLanguage?.DisplayName || "None"}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">Use Default Audio</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Play default audio track regardless of language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.playDefaultAudioTrack}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ playDefaultAudioTrack: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">
|
||||||
|
Set Audio Track From Previous Item
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||||
|
Try to set the audio track to the closest match to the last
|
||||||
|
video.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.rememberAudioSelections}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ rememberAudioSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
>
|
>
|
||||||
<ListItem title={"Set Audio Track From Previous Item"}>
|
<TouchableOpacity
|
||||||
<Switch
|
className="flex-1 bg-black/50"
|
||||||
value={settings.rememberAudioSelections}
|
activeOpacity={1}
|
||||||
onValueChange={(value) =>
|
onPress={() => setIsModalVisible(false)}
|
||||||
updateSettings({ rememberAudioSelections: value })
|
>
|
||||||
}
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
/>
|
<View className="p-4 border-b border-neutral-800">
|
||||||
</ListItem>
|
<Text className="text-lg font-bold text-center">
|
||||||
<ListItem title="Audio language">
|
Select Language
|
||||||
<DropdownMenu.Root>
|
</Text>
|
||||||
<DropdownMenu.Trigger>
|
</View>
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
|
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<View className="max-h-[50%]">
|
||||||
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
<TouchableOpacity
|
||||||
</Text>
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
<Ionicons
|
onPress={() => {
|
||||||
name="chevron-expand-sharp"
|
|
||||||
size={18}
|
|
||||||
color="#5A5960"
|
|
||||||
/>
|
|
||||||
</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({
|
updateSettings({
|
||||||
defaultAudioLanguage: null,
|
defaultAudioLanguage: null,
|
||||||
});
|
});
|
||||||
|
setIsModalVisible(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
<Text>None</Text>
|
||||||
</DropdownMenu.Item>
|
{!settings?.defaultAudioLanguage && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{cultures?.map((l) => (
|
{cultures?.map((l) => (
|
||||||
<DropdownMenu.Item
|
<TouchableOpacity
|
||||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||||
onSelect={() => {
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
defaultAudioLanguage: l,
|
defaultAudioLanguage: l,
|
||||||
});
|
});
|
||||||
|
setIsModalVisible(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<Text>{l.DisplayName}</Text>
|
||||||
{l.DisplayName}
|
{settings?.defaultAudioLanguage
|
||||||
</DropdownMenu.ItemTitle>
|
?.ThreeLetterISOLanguageName ===
|
||||||
</DropdownMenu.Item>
|
l.ThreeLetterISOLanguageName && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</View>
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
<TouchableOpacity
|
||||||
</ListGroup>
|
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,111 +0,0 @@
|
|||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import React from "react";
|
|
||||||
import { Switch, TouchableOpacity, View } from "react-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const DownloadSettings: React.FC = ({ ...props }) => {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const { setProcesses } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props} className="mb-4">
|
|
||||||
<ListGroup title="Downloads">
|
|
||||||
<ListItem title="Download method">
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
|
||||||
{settings.downloadMethod === "remux"
|
|
||||||
? "Default"
|
|
||||||
: "Optimized"}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-expand-sharp"
|
|
||||||
size={18}
|
|
||||||
color="#5A5960"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "remux" });
|
|
||||||
setProcesses([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "optimized" });
|
|
||||||
setProcesses([]);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title="Remux max download"
|
|
||||||
disabled={settings.downloadMethod !== "remux"}
|
|
||||||
>
|
|
||||||
<Stepper
|
|
||||||
value={settings.remuxConcurrentLimit}
|
|
||||||
step={1}
|
|
||||||
min={1}
|
|
||||||
max={4}
|
|
||||||
onUpdate={(value) =>
|
|
||||||
updateSettings({
|
|
||||||
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title="Auto download"
|
|
||||||
disabled={settings.downloadMethod !== "optimized"}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
disabled={settings.downloadMethod !== "optimized"}
|
|
||||||
value={settings.autoDownload}
|
|
||||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
disabled={settings.downloadMethod !== "optimized"}
|
|
||||||
onPress={() => router.push("/settings/optimized-server/page")}
|
|
||||||
showArrow
|
|
||||||
title="Optimized Versions Server"
|
|
||||||
></ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Input } from "../common/Input";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { Input } from "../common/Input";
|
||||||
|
import { ListItem } from "../ListItem";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const JellyseerrSettings = () => {
|
export const JellyseerrSettings = () => {
|
||||||
const {
|
const {
|
||||||
@@ -83,43 +83,41 @@ export const JellyseerrSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="">
|
<View className="mt-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
|
||||||
<View>
|
<View>
|
||||||
{jellyseerrUser ? (
|
{jellyseerrUser ? (
|
||||||
<>
|
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
|
||||||
<ListGroup title={"Jellyseerr"}>
|
<ListItem
|
||||||
<ListItem
|
title="Total media requests"
|
||||||
title="Total media requests"
|
subTitle={jellyseerrUser?.requestCount?.toString()}
|
||||||
value={jellyseerrUser?.requestCount?.toString()}
|
/>
|
||||||
/>
|
<ListItem
|
||||||
<ListItem
|
title="Movie quota limit"
|
||||||
title="Movie quota limit"
|
subTitle={
|
||||||
value={
|
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
||||||
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<ListItem
|
||||||
<ListItem
|
title="Movie quota days"
|
||||||
title="Movie quota days"
|
subTitle={
|
||||||
value={
|
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
||||||
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<ListItem
|
||||||
<ListItem
|
title="TV quota limit"
|
||||||
title="TV quota limit"
|
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
||||||
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
/>
|
||||||
/>
|
<ListItem
|
||||||
<ListItem
|
title="TV quota days"
|
||||||
title="TV quota days"
|
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
||||||
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
/>
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<Button color="red" onPress={clearData}>
|
<Button color="red" onPress={clearData}>
|
||||||
Reset Jellyseerr config
|
Reset Jellyseerr config
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||||
<Text className="text-xs text-red-600 mb-2">
|
<Text className="text-xs text-red-600 mb-2">
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import React from "react";
|
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
@@ -12,61 +9,86 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const renderSkipControl = (
|
|
||||||
value: number,
|
|
||||||
onDecrease: () => void,
|
|
||||||
onIncrease: () => void
|
|
||||||
) => (
|
|
||||||
<View className="flex flex-row items-center">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onDecrease}
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Text>-</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
|
||||||
{value}s
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
|
||||||
onPress={onIncrease}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View>
|
||||||
<ListGroup title="Media Controls">
|
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||||
<ListItem title="Forward Skip Length">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
{renderSkipControl(
|
<View
|
||||||
settings.forwardSkipTime,
|
className={`
|
||||||
() =>
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
updateSettings({
|
`}
|
||||||
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
>
|
||||||
}),
|
<View className="flex flex-col shrink">
|
||||||
() =>
|
<Text className="font-semibold">Forward skip length</Text>
|
||||||
updateSettings({
|
<Text className="text-xs opacity-50">
|
||||||
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
Choose length in seconds when skipping in video playback.
|
||||||
})
|
</Text>
|
||||||
)}
|
</View>
|
||||||
</ListItem>
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
|
{settings.forwardSkipTime}s
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<ListItem title="Rewind Length">
|
<View
|
||||||
{renderSkipControl(
|
className={`
|
||||||
settings.rewindSkipTime,
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
() =>
|
`}
|
||||||
updateSettings({
|
>
|
||||||
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
<View className="flex flex-col shrink">
|
||||||
}),
|
<Text className="font-semibold">Rewind length</Text>
|
||||||
() =>
|
<Text className="text-xs opacity-50">
|
||||||
updateSettings({
|
Choose length in seconds when skipping in video playback.
|
||||||
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
</Text>
|
||||||
})
|
</View>
|
||||||
)}
|
<View className="flex flex-row items-center">
|
||||||
</ListItem>
|
<TouchableOpacity
|
||||||
</ListGroup>
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
|
{settings.rewindSkipTime}s
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { TextInput, View, Linking } from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
value: string;
|
|
||||||
onChangeValue: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OptimizedServerForm: React.FC<Props> = ({
|
|
||||||
value,
|
|
||||||
onChangeValue,
|
|
||||||
}) => {
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
|
||||||
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
|
|
||||||
<Text className="mr-4">URL</Text>
|
|
||||||
<TextInput
|
|
||||||
className="text-white"
|
|
||||||
placeholder="http(s)://domain.org:port"
|
|
||||||
value={value}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => onChangeValue(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
|
||||||
Enter the URL for the optimize server. The URL should include http or
|
|
||||||
https and optionally the port.{" "}
|
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
|
||||||
Read more about the optimize server.
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
|
||||||
BACKGROUND_FETCH_TASK,
|
|
||||||
registerBackgroundFetchAsync,
|
|
||||||
unregisterBackgroundFetchAsync,
|
|
||||||
} from "@/utils/background-tasks";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const OtherSettings: React.FC = () => {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
/********************
|
|
||||||
* 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]);
|
|
||||||
/**********************
|
|
||||||
*********************/
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ListGroup title="Other" className="mb-4">
|
|
||||||
<ListItem title="Auto rotate">
|
|
||||||
<Switch
|
|
||||||
value={settings.autoRotate}
|
|
||||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title="Video orientation" disabled={settings.autoRotate}>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
|
||||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
|
||||||
</Text>
|
|
||||||
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
|
|
||||||
</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>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title="Safe area in controls">
|
|
||||||
<Switch
|
|
||||||
value={settings.safeAreaInControlsEnabled}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title="Show Custom Menu Links"
|
|
||||||
onPress={() =>
|
|
||||||
Linking.openURL(
|
|
||||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.showCustomMenuLinks}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ showCustomMenuLinks: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const PluginSettings = () => {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<ListGroup title="Plugins">
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/jellyseerr/page")}
|
|
||||||
title={"Jellyseerr"}
|
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/marlin-search/page")}
|
|
||||||
title="Marlin Search"
|
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/popular-lists/page")}
|
|
||||||
title="Popular Lists"
|
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetTextInput,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useRef, useState } from "react";
|
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const authorizeQuickConnect = useCallback(async () => {
|
|
||||||
if (quickConnectCode) {
|
|
||||||
try {
|
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
|
||||||
code: quickConnectCode,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
|
||||||
setQuickConnectCode(undefined);
|
|
||||||
bottomSheetModalRef?.current?.close();
|
|
||||||
} else {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [api, user, quickConnectCode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<ListGroup title={"Quick Connect"}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
|
||||||
title="Authorize Quick Connect"
|
|
||||||
textColor="blue"
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
|
||||||
Quick Connect
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
|
||||||
<BottomSheetTextInput
|
|
||||||
style={{ color: "white" }}
|
|
||||||
clearButtonMode="always"
|
|
||||||
placeholder="Enter the quick connect code..."
|
|
||||||
placeholderTextColor="#9CA3AF"
|
|
||||||
value={quickConnectCode}
|
|
||||||
onChangeText={setQuickConnectCode}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
className="mt-auto"
|
|
||||||
onPress={authorizeQuickConnect}
|
|
||||||
color="purple"
|
|
||||||
>
|
|
||||||
Authorize
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
308
components/settings/SettingToggles.tsx
Normal file
308
components/settings/SettingToggles.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Modal,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
import { AudioToggles } from "./AudioToggles";
|
||||||
|
import { JellyseerrSettings } from "./Jellyseerr";
|
||||||
|
import { MediaProvider } from "./MediaContext";
|
||||||
|
import { MediaToggles } from "./MediaToggles";
|
||||||
|
import { SubtitleToggles } from "./SubtitleToggles";
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isSearchEngineModalVisible, setIsSearchEngineModalVisible] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mediaListCollections,
|
||||||
|
isLoading: isLoadingMediaListCollections,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["sf_promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
type SearchEngine = "Jellyfin" | "Marlin";
|
||||||
|
|
||||||
|
const searchEngines: Array<{ id: SearchEngine; name: string }> = [
|
||||||
|
{ id: "Jellyfin", name: "Jellyfin" },
|
||||||
|
{ id: "Marlin", name: "Marlin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
{/* <View>
|
||||||
|
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
||||||
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="shrink">
|
||||||
|
<Text className="font-semibold">Coming soon</Text>
|
||||||
|
<Text className="text-xs opacity-50 max-w-[90%]">
|
||||||
|
Options for changing the look and feel of the app.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch disabled />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View> */}
|
||||||
|
|
||||||
|
<MediaProvider>
|
||||||
|
<MediaToggles />
|
||||||
|
<AudioToggles />
|
||||||
|
<SubtitleToggles />
|
||||||
|
</MediaProvider>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
|
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="shrink">
|
||||||
|
<Text className="font-semibold">Auto rotate</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Important on android since the video player orientation is
|
||||||
|
locked to the app orientation.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.autoRotate}
|
||||||
|
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="shrink">
|
||||||
|
<Text className="font-semibold">Safe area in controls</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Enable safe area in video player controls
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.safeAreaInControlsEnabled}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||||
|
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(
|
||||||
|
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.usePopularPlugin}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ usePopularPlugin: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{settings.usePopularPlugin && (
|
||||||
|
<View className="flex flex-col py-2 bg-neutral-900">
|
||||||
|
{mediaListCollections?.map((mlc) => (
|
||||||
|
<View
|
||||||
|
key={mlc.Id}
|
||||||
|
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
||||||
|
>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">{mlc.Name}</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!settings.mediaListCollectionIds) {
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds: [mlc.Id!],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds:
|
||||||
|
settings.mediaListCollectionIds.includes(mlc.Id!)
|
||||||
|
? settings.mediaListCollectionIds.filter(
|
||||||
|
(id) => id !== mlc.Id
|
||||||
|
)
|
||||||
|
: [...settings.mediaListCollectionIds, mlc.Id!],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{isLoadingMediaListCollections && (
|
||||||
|
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{mediaListCollections?.length === 0 && (
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
No collections found. Add some in Jellyfin.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Search engine</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose the search engine you want to use.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
|
onPress={() => setIsSearchEngineModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text>{settings.searchEngine}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings.searchEngine === "Marlin" && (
|
||||||
|
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<View className="grow">
|
||||||
|
<Input
|
||||||
|
placeholder="Marlin Server URL..."
|
||||||
|
defaultValue={settings.marlinServerUrl}
|
||||||
|
value={marlinUrl}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => setMarlinUrl(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
className="shrink w-16 h-12"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: marlinUrl.endsWith("/")
|
||||||
|
? marlinUrl
|
||||||
|
: marlinUrl + "/",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings.marlinServerUrl && (
|
||||||
|
<Text className="text-neutral-500 mt-2">
|
||||||
|
Current: {settings.marlinServerUrl}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isSearchEngineModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsSearchEngineModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Search Engine
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{searchEngines.map((engine) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={engine.id}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
searchEngine: engine.id,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
setIsSearchEngineModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{engine.name}</Text>
|
||||||
|
{settings.searchEngine === engine.id && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { clearLogs } from "@/utils/log";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import * as Progress from "react-native-progress";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const StorageSettings = () => {
|
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
|
||||||
|
|
||||||
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 onDeleteClicked = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
toast.error("Error deleting files");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
|
||||||
return ((value / total) * 100).toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-col gap-y-1">
|
|
||||||
<View className="flex flex-row items-center justify-between">
|
|
||||||
<Text className="">Storage</Text>
|
|
||||||
{size && (
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
{Number(size.total - size.remaining).bytesToReadable()} of{" "}
|
|
||||||
{size.total?.bytesToReadable()} used
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View className="h-3 w-full bg-gray-100/10 rounded-md overflow-hidden flex flex-row">
|
|
||||||
{size && (
|
|
||||||
<>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: `${(size.app / size.total) * 100}%`,
|
|
||||||
backgroundColor: "rgb(147 51 234)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: `${
|
|
||||||
((size.total - size.remaining - size.app) / size.total) *
|
|
||||||
100
|
|
||||||
}%`,
|
|
||||||
backgroundColor: "rgb(192 132 252)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row gap-x-2">
|
|
||||||
{size && (
|
|
||||||
<>
|
|
||||||
<View className="flex flex-row items-center">
|
|
||||||
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
|
|
||||||
<Text className="text-white text-xs">
|
|
||||||
App {calculatePercentage(size.app, size.total)}%
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center">
|
|
||||||
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
|
|
||||||
<Text className="text-white text-xs">
|
|
||||||
Phone{" "}
|
|
||||||
{calculatePercentage(
|
|
||||||
size.total - size.remaining - size.app,
|
|
||||||
size.total
|
|
||||||
)}
|
|
||||||
%
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ListGroup>
|
|
||||||
<ListItem
|
|
||||||
textColor="red"
|
|
||||||
onPress={onDeleteClicked}
|
|
||||||
title="Delete All Downloaded Files"
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useMedia } from "./MediaContext";
|
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useMedia } from "./MediaContext";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -14,6 +12,8 @@ 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;
|
||||||
|
|
||||||
@@ -26,117 +26,87 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View>
|
||||||
<ListGroup
|
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
||||||
title={"Subtitles"}
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
description={
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
<Text className="text-[#8E8D91] text-xs">
|
<View className="flex flex-col shrink">
|
||||||
Configure subtitle preferences.
|
<Text className="font-semibold">Subtitle language</Text>
|
||||||
</Text>
|
<Text className="text-xs opacity-50">
|
||||||
}
|
Choose a default subtitle language.
|
||||||
>
|
</Text>
|
||||||
<ListItem title="Subtitle language">
|
</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="flex flex-row items-center justify-between py-3 pl-3">
|
onPress={() => setIsLanguageModalVisible(true)}
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
>
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
<Text>
|
||||||
</Text>
|
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||||
<Ionicons
|
</Text>
|
||||||
name="chevron-expand-sharp"
|
<Ionicons
|
||||||
size={18}
|
name="chevron-down"
|
||||||
color="#5A5960"
|
size={16}
|
||||||
/>
|
color="white"
|
||||||
</TouchableOpacity>
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
</DropdownMenu.Trigger>
|
/>
|
||||||
<DropdownMenu.Content
|
</TouchableOpacity>
|
||||||
loop={true}
|
</View>
|
||||||
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>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title="Subtitle Mode">
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
<DropdownMenu.Root>
|
<View className="flex flex-col shrink">
|
||||||
<DropdownMenu.Trigger>
|
<Text className="font-semibold">Subtitle Mode</Text>
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<Text className="text-xs opacity-50 mr-2">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
Subtitles are loaded based on the default and forced flags in the
|
||||||
{settings?.subtitleMode || "Loading"}
|
embedded metadata. Language preferences are considered when
|
||||||
</Text>
|
multiple options are available.
|
||||||
<Ionicons
|
</Text>
|
||||||
name="chevron-expand-sharp"
|
</View>
|
||||||
size={18}
|
<TouchableOpacity
|
||||||
color="#5A5960"
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
/>
|
onPress={() => setIsModeModalVisible(true)}
|
||||||
</TouchableOpacity>
|
>
|
||||||
</DropdownMenu.Trigger>
|
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
||||||
<DropdownMenu.Content
|
<Ionicons
|
||||||
loop={true}
|
name="chevron-down"
|
||||||
side="bottom"
|
size={16}
|
||||||
align="start"
|
color="white"
|
||||||
alignOffset={0}
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
avoidCollisions={true}
|
/>
|
||||||
collisionPadding={8}
|
</TouchableOpacity>
|
||||||
sideOffset={8}
|
</View>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title="Set Subtitle Track From Previous Item">
|
<View className="flex flex-col">
|
||||||
<Switch
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
value={settings.rememberSubtitleSelections}
|
<View className="flex flex-col">
|
||||||
onValueChange={(value) =>
|
<Text className="font-semibold">
|
||||||
updateSettings({ rememberSubtitleSelections: value })
|
Set Subtitle Track From Previous Item
|
||||||
}
|
</Text>
|
||||||
/>
|
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||||
</ListItem>
|
Try to set the subtitle track to the closest match to the last
|
||||||
|
video.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.rememberSubtitleSelections}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<ListItem title="Subtitle Size">
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle Size</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default subtitle size for direct play (only works for
|
||||||
|
some subtitle formats).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<View className="flex flex-row items-center">
|
<View className="flex flex-row items-center">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
@@ -148,7 +118,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
>
|
>
|
||||||
<Text>-</Text>
|
<Text>-</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
{settings.subtitleSize}
|
{settings.subtitleSize}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -162,8 +132,121 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<Text>+</Text>
|
<Text>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ListItem>
|
</View>
|
||||||
</ListGroup>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import Constants from "expo-constants";
|
|
||||||
import Application from "expo-application";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const version =
|
|
||||||
Application?.nativeApplicationVersion ||
|
|
||||||
Application?.nativeBuildVersion ||
|
|
||||||
"N/A";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<ListGroup title={"User Info"}>
|
|
||||||
<ListItem title="User" value={user?.Name} />
|
|
||||||
<ListItem title="Server" value={api?.basePath} />
|
|
||||||
<ListItem title="Token" value={api?.accessToken} />
|
|
||||||
<ListItem title="App version" value={version} />
|
|
||||||
</ListGroup>
|
|
||||||
</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
|
return [...embeddedSubs, ...externalSubs] as (
|
||||||
if (!offline) {
|
| EmbeddedSubtitle
|
||||||
return [...embeddedSubs, ...externalSubs] as (
|
| ExternalSubtitle
|
||||||
| EmbeddedSubtitle
|
)[];
|
||||||
| 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;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
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 (
|
||||||
<DropdownMenu.Root>
|
<>
|
||||||
<DropdownMenu.Trigger>
|
<TouchableOpacity
|
||||||
<TouchableOpacity 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"
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
onPress={() => setIsMainModalVisible(true)}
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={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}
|
<TouchableOpacity
|
||||||
>
|
className="flex-1 bg-black/50"
|
||||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
activeOpacity={1}
|
||||||
<DropdownMenu.CheckboxItem
|
onPress={closeAllModals}
|
||||||
key={`subtitle-item-${idx}`}
|
>
|
||||||
value={subtitleIndex === sub.index.toString()}
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
onValueChange={() => {
|
{!activeSubMenu ? (
|
||||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
<>
|
||||||
setSubtitleURL &&
|
<View className="p-4 border-b border-neutral-800">
|
||||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
<Text className="text-lg font-bold text-center">
|
||||||
} else {
|
Settings
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
</Text>
|
||||||
}
|
</View>
|
||||||
router.setParams({
|
<View>
|
||||||
subtitleIndex: sub.index.toString(),
|
<MenuOption
|
||||||
});
|
label="Subtitle"
|
||||||
}}
|
onPress={() => setActiveSubMenu("subtitle")}
|
||||||
>
|
/>
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
<MenuOption
|
||||||
{sub.name}
|
label="Audio"
|
||||||
</DropdownMenu.ItemTitle>
|
onPress={() => setActiveSubMenu("audio")}
|
||||||
</DropdownMenu.CheckboxItem>
|
/>
|
||||||
))}
|
</View>
|
||||||
</DropdownMenu.SubContent>
|
</>
|
||||||
</DropdownMenu.Sub>
|
) : activeSubMenu === "subtitle" ? (
|
||||||
<DropdownMenu.Sub>
|
<>
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
Audio
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
</DropdownMenu.SubTrigger>
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
<DropdownMenu.SubContent
|
</TouchableOpacity>
|
||||||
alignOffset={-10}
|
<Text className="text-lg font-bold ml-2">Subtitle</Text>
|
||||||
avoidCollisions={true}
|
</View>
|
||||||
collisionPadding={0}
|
<View className="max-h-[50%]">
|
||||||
loop={true}
|
{allSubtitleTracksForDirectPlay?.map((sub, idx) => (
|
||||||
sideOffset={10}
|
<TouchableOpacity
|
||||||
>
|
key={`subtitle-${idx}`}
|
||||||
{audioTracks?.map((track, idx: number) => (
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
<DropdownMenu.CheckboxItem
|
onPress={() => {
|
||||||
key={`audio-item-${idx}`}
|
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||||
value={audioIndex === track.index.toString()}
|
setSubtitleURL?.(
|
||||||
onValueChange={() => {
|
api?.basePath + sub.deliveryUrl,
|
||||||
setAudioTrack && setAudioTrack(track.index);
|
sub.name
|
||||||
router.setParams({
|
);
|
||||||
audioIndex: track.index.toString(),
|
} else {
|
||||||
});
|
setSubtitleTrack?.(sub.index);
|
||||||
}}
|
}
|
||||||
>
|
router.setParams({
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
subtitleIndex: sub.index.toString(),
|
||||||
{track.name}
|
});
|
||||||
</DropdownMenu.ItemTitle>
|
closeAllModals();
|
||||||
</DropdownMenu.CheckboxItem>
|
}}
|
||||||
))}
|
>
|
||||||
</DropdownMenu.SubContent>
|
<Text>{sub.name}</Text>
|
||||||
</DropdownMenu.Sub>
|
{subtitleIndex === sub.index.toString() && (
|
||||||
</DropdownMenu.Content>
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
</DropdownMenu.Root>
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{audioTracks?.map((track, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`audio-${idx}`}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
setAudioTrack?.(track.index);
|
||||||
|
router.setParams({
|
||||||
|
audioIndex: track.index.toString(),
|
||||||
|
});
|
||||||
|
closeAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{track.name}</Text>
|
||||||
|
{audioIndex === track.index.toString() && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={closeAllModals}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
import { 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,108 +151,135 @@ 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"} />
|
>
|
||||||
</TouchableOpacity>
|
<Ionicons name="ellipsis-horizontal" size={24} color="white" />
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
<Modal
|
||||||
side="bottom"
|
visible={isMainModalVisible}
|
||||||
align="start"
|
transparent
|
||||||
alignOffset={0}
|
animationType="slide"
|
||||||
avoidCollisions={true}
|
onRequestClose={closeAllModals}
|
||||||
collisionPadding={8}
|
>
|
||||||
sideOffset={8}
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={closeAllModals}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Sub>
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
{!activeSubMenu ? (
|
||||||
Subtitle
|
<>
|
||||||
</DropdownMenu.SubTrigger>
|
<View className="p-4 border-b border-neutral-800">
|
||||||
<DropdownMenu.SubContent
|
<Text className="text-lg font-bold text-center">
|
||||||
alignOffset={-10}
|
Settings
|
||||||
avoidCollisions={true}
|
</Text>
|
||||||
collisionPadding={0}
|
</View>
|
||||||
loop={true}
|
<View>
|
||||||
sideOffset={10}
|
<MenuOption
|
||||||
>
|
label="Subtitle"
|
||||||
{allSubtitleTracksForTranscodingStream?.map(
|
onPress={() => setActiveSubMenu("subtitle")}
|
||||||
(sub, idx: number) => (
|
/>
|
||||||
<DropdownMenu.CheckboxItem
|
<MenuOption
|
||||||
value={
|
label="Audio"
|
||||||
subtitleIndex ===
|
onPress={() => setActiveSubMenu("audio")}
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
/>
|
||||||
? subtitleHelper
|
</View>
|
||||||
|
</>
|
||||||
|
) : activeSubMenu === "subtitle" ? (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Subtitle</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{allSubtitleTracksForTranscodingStream?.map((sub, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`subtitle-${idx}`}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
if (
|
||||||
|
subtitleIndex ===
|
||||||
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
|
? subtitleHelper
|
||||||
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString()
|
||||||
|
: sub?.index.toString())
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
router.setParams({
|
||||||
|
subtitleIndex: subtitleHelper
|
||||||
.getSourceSubtitleIndex(sub.index)
|
.getSourceSubtitleIndex(sub.index)
|
||||||
.toString()
|
.toString(),
|
||||||
: sub?.index.toString())
|
});
|
||||||
}
|
|
||||||
key={`subtitle-item-${idx}`}
|
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||||
onValueChange={() => {
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
if (
|
} else {
|
||||||
subtitleIndex ===
|
changeToImageBasedSub(sub.index);
|
||||||
|
}
|
||||||
|
closeAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{sub.name}</Text>
|
||||||
|
{subtitleIndex ===
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
? subtitleHelper
|
? subtitleHelper
|
||||||
.getSourceSubtitleIndex(sub.index)
|
.getSourceSubtitleIndex(sub.index)
|
||||||
.toString()
|
.toString()
|
||||||
: sub?.index.toString())
|
: sub?.index.toString()) && (
|
||||||
)
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
return;
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{allAudio?.map((track, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`audio-${idx}`}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
if (audioIndex === track.index.toString()) return;
|
||||||
|
router.setParams({
|
||||||
|
audioIndex: track.index.toString(),
|
||||||
|
});
|
||||||
|
ChangeTranscodingAudio(track.index);
|
||||||
|
closeAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{track.name}</Text>
|
||||||
|
{audioIndex === track.index.toString() && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
router.setParams({
|
<TouchableOpacity
|
||||||
subtitleIndex: subtitleHelper
|
className="p-4 border-t border-neutral-800"
|
||||||
.getSourceSubtitleIndex(sub.index)
|
onPress={closeAllModals}
|
||||||
.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
changeToImageBasedSub(sub.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
|
||||||
{sub.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
|
||||||
Audio
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
>
|
||||||
{allAudio?.map((track, idx: number) => (
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
<DropdownMenu.CheckboxItem
|
</TouchableOpacity>
|
||||||
key={`audio-item-${idx}`}
|
</View>
|
||||||
value={audioIndex === track.index.toString()}
|
</TouchableOpacity>
|
||||||
onValueChange={() => {
|
</Modal>
|
||||||
if (audioIndex === track.index.toString()) return;
|
|
||||||
router.setParams({
|
|
||||||
audioIndex: track.index.toString(),
|
|
||||||
});
|
|
||||||
ChangeTranscodingAudio(track.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
|
||||||
{track.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</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 };
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user