forked from Ninjalama/streamyfin_mirror
Compare commits
68 Commits
l10n_devel
...
v0.25.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f28f1d8736 | ||
|
|
e0f03ccb93 | ||
|
|
34d1dbb20e | ||
|
|
e3e2db659d | ||
|
|
528b4ad7ac | ||
|
|
d29501386b | ||
|
|
6688469b6c | ||
|
|
ae9c30aa6d | ||
|
|
364d2e8a51 | ||
|
|
6cc90b46b3 | ||
|
|
33adea2819 | ||
|
|
9f41861dcf | ||
|
|
2b2d23e574 | ||
|
|
f6e2bcb120 | ||
|
|
314cd62bee | ||
|
|
41e7123d1c | ||
|
|
2af42b39f5 | ||
|
|
0a06b336c8 | ||
|
|
028c9159f3 | ||
|
|
dee4fa07e3 | ||
|
|
2764f1736a | ||
|
|
d3d1a7bcde | ||
|
|
7fcd598fa1 | ||
|
|
0fc1506b11 | ||
|
|
e0aa7ea0df | ||
|
|
25f77645f8 | ||
|
|
1c81091e8b | ||
|
|
94502b558d | ||
|
|
a7d7d00eb3 | ||
|
|
3b5e07c1d2 | ||
|
|
db10369fb5 | ||
|
|
32da5918c7 | ||
|
|
dc542021b5 | ||
|
|
bfad157a28 | ||
|
|
a71a646743 | ||
|
|
366bc0137e | ||
|
|
3eb60840e6 | ||
|
|
65c4a1340d | ||
|
|
3e90447dd4 | ||
|
|
bd0768797e | ||
|
|
730ef4616f | ||
|
|
c4d4475aa9 | ||
|
|
d1eb40f2a9 | ||
|
|
77518d774e | ||
|
|
a6fb7b956d | ||
|
|
034ff3f478 | ||
|
|
98ca4e7a6d | ||
|
|
461a276a20 | ||
|
|
3975473da9 | ||
|
|
d34b86297a | ||
|
|
c4a83e283f | ||
|
|
dac471f0a6 | ||
|
|
3cd8e41000 | ||
|
|
dd08826931 | ||
|
|
b681025389 | ||
|
|
65549428bf | ||
|
|
cda3b64a2b | ||
|
|
373d4ca3b1 | ||
|
|
8bc360d554 | ||
|
|
3fae21d559 | ||
|
|
74ce9d7eea | ||
|
|
5055a700c9 | ||
|
|
ab33693dd9 | ||
|
|
6a4621c377 | ||
|
|
2fb19f601b | ||
|
|
a602c35a8f | ||
|
|
46ac4a2cc7 | ||
|
|
962f65874e |
41
.github/workflows/lint-pr.yaml
vendored
Normal file
41
.github/workflows/lint-pr.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: "Lint PR"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,6 +26,10 @@ package-lock.json
|
|||||||
|
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
/iostv
|
||||||
|
/iosmobile
|
||||||
|
/androidmobile
|
||||||
|
/androidtv
|
||||||
|
|
||||||
modules/player/android
|
modules/player/android
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
|
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
@@ -85,7 +86,13 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed.
|
3. Make sure you have xcode and/or android studio installed.
|
||||||
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app.
|
4. run `npm run prebuild`
|
||||||
|
5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
|
`npm run prebuild:tv`
|
||||||
|
`npm run ios:tv or npm run android:tv`
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
|
|||||||
11
app.config.js
Normal file
11
app.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
if (process.env.EXPO_TV != "1") {
|
||||||
|
config.plugins.push([
|
||||||
|
"react-native-google-cast",
|
||||||
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
};
|
||||||
72
app.json
72
app.json
@@ -7,19 +7,19 @@
|
|||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"splash": {
|
|
||||||
"image": "./assets/images/splash.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#2E2E2E"
|
|
||||||
},
|
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio", "fetch"],
|
"UIBackgroundModes": [
|
||||||
|
"audio",
|
||||||
|
"fetch"
|
||||||
|
],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
@@ -48,15 +48,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"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",
|
||||||
{
|
{
|
||||||
@@ -78,18 +73,19 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"android": {
|
"compileSdkVersion": 35,
|
||||||
"compileSdkVersion": 34,
|
"targetSdkVersion": 35,
|
||||||
"targetSdkVersion": 34,
|
"buildToolsVersion": "35.0.0",
|
||||||
"buildToolsVersion": "34.0.0"
|
"kotlinVersion": "2.0.21",
|
||||||
},
|
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 24,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
"jniLibs": {
|
"jniLibs": {
|
||||||
"useLegacyPackaging": true
|
"useLegacyPackaging": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"useAndroidX": true,
|
||||||
|
"enableJetifier": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -109,12 +105,35 @@
|
|||||||
"expo-asset",
|
"expo-asset",
|
||||||
[
|
[
|
||||||
"react-native-edge-to-edge",
|
"react-native-edge-to-edge",
|
||||||
{ "android": { "parentTheme": "Material3" } }
|
{
|
||||||
|
"android": {
|
||||||
|
"parentTheme": "Material3"
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
[
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
"react-native-bottom-tabs"
|
||||||
["./plugins/withGoogleCastActivity.js"],
|
],
|
||||||
["./plugins/withTrustLocalCerts.js"]
|
[
|
||||||
|
"./plugins/withChangeNativeAndroidTextToWhite.js"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withGoogleCastActivity.js"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withTrustLocalCerts.js"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withGradleProperties.js"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#2e2e2e",
|
||||||
|
"image": "./assets/images/StreamyFinFinal.png",
|
||||||
|
"imageWidth": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -133,6 +152,7 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
}
|
},
|
||||||
|
"newArchEnabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
export interface MenuLink {
|
export interface MenuLink {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -52,7 +54,13 @@ export default function menuLinks() {
|
|||||||
}}
|
}}
|
||||||
data={menuLinks}
|
data={menuLinks}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
WebBrowser.openBrowserAsync(item.url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={item.name}
|
title={item.name}
|
||||||
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
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";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -25,14 +24,18 @@ export default function IndexLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
{!Platform.isTV && (
|
||||||
<TouchableOpacity
|
<>
|
||||||
onPress={() => {
|
<Chromecast.Chromecast />
|
||||||
router.push("/(auth)/settings");
|
<TouchableOpacity
|
||||||
}}
|
onPress={() => {
|
||||||
>
|
router.push("/(auth)/settings");
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
}}
|
||||||
</TouchableOpacity>
|
>
|
||||||
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Colors } from "@/constants/Colors";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { HomeSectionStyle, 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";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +27,7 @@ import { QueryFunction, useQuery } from "@tanstack/react-query";
|
|||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -36,6 +37,10 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import {
|
||||||
|
useSplashScreenLoading,
|
||||||
|
useSplashScreenVisible,
|
||||||
|
} from "@/providers/SplashScreenProvider";
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type ScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
@@ -73,30 +78,38 @@ 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(() => {
|
if (!Platform.isTV) {
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||||
navigation.setOptions({
|
useEffect(() => {
|
||||||
headerLeft: () => (
|
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||||
<TouchableOpacity
|
navigation.setOptions({
|
||||||
onPress={() => {
|
headerLeft: () => (
|
||||||
router.push("/(auth)/downloads");
|
<TouchableOpacity
|
||||||
}}
|
onPress={() => {
|
||||||
className="p-2"
|
router.push("/(auth)/downloads");
|
||||||
>
|
}}
|
||||||
<Feather
|
className="p-2"
|
||||||
name="download"
|
>
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
<Feather
|
||||||
size={22}
|
name="download"
|
||||||
/>
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
</TouchableOpacity>
|
size={22}
|
||||||
),
|
/>
|
||||||
});
|
</TouchableOpacity>
|
||||||
}, [downloadedFiles, navigation, router]);
|
),
|
||||||
|
});
|
||||||
|
}, [downloadedFiles, navigation, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanCacheDirectory().catch((e) =>
|
||||||
|
console.error("Something went wrong cleaning cache directory")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
const checkConnection = useCallback(async () => {
|
||||||
setLoadingRetry(true);
|
setLoadingRetry(true);
|
||||||
@@ -116,9 +129,9 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanCacheDirectory().catch((e) =>
|
// cleanCacheDirectory().catch((e) =>
|
||||||
console.error("Something went wrong cleaning cache directory")
|
// console.error("Something went wrong cleaning cache directory")
|
||||||
);
|
// );
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
@@ -146,6 +159,10 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// show splash screen until query loaded
|
||||||
|
useSplashScreenLoading(l1);
|
||||||
|
const splashScreenVisible = useSplashScreenVisible();
|
||||||
|
|
||||||
const userViews = useMemo(
|
const userViews = useMemo(
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
[data, settings?.hiddenLibraries]
|
[data, settings?.hiddenLibraries]
|
||||||
@@ -207,7 +224,7 @@ export default function index() {
|
|||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
const title = t("home.recently_added_in", {libraryName: c.Name});
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey = [
|
const queryKey = [
|
||||||
"home",
|
"home",
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
"recentlyAddedIn" + c.CollectionType,
|
||||||
@@ -308,6 +325,7 @@ export default function index() {
|
|||||||
const ss: Section[] = [];
|
const ss: Section[] = [];
|
||||||
|
|
||||||
for (const key in settings.home?.sections) {
|
for (const key in settings.home?.sections) {
|
||||||
|
// @ts-expect-error
|
||||||
const section = settings.home?.sections[key];
|
const section = settings.home?.sections[key];
|
||||||
const id = section.title || key;
|
const id = section.title || key;
|
||||||
ss.push({
|
ss.push({
|
||||||
@@ -352,7 +370,7 @@ export default function index() {
|
|||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||||
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
||||||
<Text className="text-center opacity-70">
|
<Text className="text-center opacity-70">
|
||||||
{t("home.no_internet_message")}
|
{t("home.no_internet_message")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="mt-4">
|
<View className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -393,11 +411,15 @@ export default function index() {
|
|||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
||||||
<Text className="text-center opacity-70">{t("home.error_message")}</Text>
|
<Text className="text-center opacity-70">
|
||||||
|
{t("home.error_message")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
// this spinner should only show up, when user navigates here
|
||||||
|
// on launch the splash screen is used for loading
|
||||||
|
if (l1 && !splashScreenVisible)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -429,6 +451,7 @@ export default function index() {
|
|||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (section.type === "MediaListSection") {
|
} else if (section.type === "MediaListSection") {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Feather, Ionicons } from "@expo/vector-icons";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import {useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -30,10 +30,10 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold">{t("home.intro.features_title")}</Text>
|
<Text className="text-lg font-bold">
|
||||||
<Text className="text-xs">
|
{t("home.intro.features_title")}
|
||||||
{t("home.intro.features_description")}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text className="text-xs">{t("home.intro.features_description")}</Text>
|
||||||
<View className="flex flex-row items-center mt-4">
|
<View className="flex flex-row items-center mt-4">
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
@@ -60,7 +60,9 @@ export default function page() {
|
|||||||
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className="shrink ml-2">
|
||||||
<Text className="font-bold mb-1">{t("home.intro.downloads_feature_title")}</Text>
|
<Text className="font-bold mb-1">
|
||||||
|
{t("home.intro.downloads_feature_title")}
|
||||||
|
</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.downloads_feature_description")}
|
{t("home.intro.downloads_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -94,7 +96,9 @@ export default function page() {
|
|||||||
<Feather name="settings" size={28} color={"white"} />
|
<Feather name="settings" size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className="shrink ml-2">
|
||||||
<Text className="font-bold mb-1">{t("home.intro.centralised_settings_plugin_title")}</Text>
|
<Text className="font-bold mb-1">
|
||||||
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
|
</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
<Text
|
<Text
|
||||||
@@ -127,7 +131,9 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
|
<Text className="text-purple-600 text-center">
|
||||||
|
{t("home.intro.go_to_settings_button")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
@@ -17,10 +17,13 @@ import { clearLogs } from "@/utils/log";
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { useEffect } from "react";
|
import React, { lazy, useEffect } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
const DownloadSettings = lazy(
|
||||||
|
() => import("@/components/settings/DownloadSettings")
|
||||||
|
);
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -42,7 +45,9 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-red-600">{t("home.settings.log_out_button")}</Text>
|
<Text className="text-red-600">
|
||||||
|
{t("home.settings.log_out_button")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -66,11 +71,12 @@ export default function settings() {
|
|||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
<DownloadSettings />
|
|
||||||
|
{!Platform.isTV && <DownloadSettings />}
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
<AppLanguageSelector/>
|
<AppLanguageSelector />
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
<ListGroup title={"Intro"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ 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 * as ScreenOrientation from "@/packages/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";
|
||||||
|
|||||||
@@ -29,13 +29,19 @@ import {
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
import React, {
|
||||||
import { TouchableOpacity, View } from "react-native";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -79,7 +85,8 @@ const Page: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -112,20 +119,22 @@ const Page: React.FC = () => {
|
|||||||
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),
|
||||||
}
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
advancedReqModalRef?.current?.present?.(body)
|
advancedReqModalRef?.current?.present?.(body);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestMedia(mediaTitle, body, refetch);
|
requestMedia(mediaTitle, body, refetch);
|
||||||
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
||||||
|
|
||||||
const isAnime = useMemo(
|
const isAnime = useMemo(
|
||||||
() => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
|
() =>
|
||||||
|
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||||
|
result.mediaType === MediaType.TV,
|
||||||
[details]
|
[details]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
@@ -247,7 +256,7 @@ const Page: React.FC = () => {
|
|||||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
onAdvancedRequest={(data) =>
|
onAdvancedRequest={(data) =>
|
||||||
advancedReqModalRef?.current?.present(data)
|
advancedReqModalRef?.current?.present(data)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
<DetailFacts
|
||||||
@@ -265,8 +274,8 @@ const Page: React.FC = () => {
|
|||||||
type={result.mediaType as MediaType}
|
type={result.mediaType as MediaType}
|
||||||
isAnime={isAnime}
|
isAnime={isAnime}
|
||||||
onRequested={() => {
|
onRequested={() => {
|
||||||
advancedReqModalRef?.current?.close()
|
advancedReqModalRef?.current?.close();
|
||||||
refetch()
|
refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -313,7 +322,9 @@ const Page: React.FC = () => {
|
|||||||
collisionPadding={0}
|
collisionPadding={0}
|
||||||
sideOffset={0}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{t("jellyseerr.types")}</DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
|
{t("jellyseerr.types")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
{Object.entries(IssueTypeName)
|
{Object.entries(IssueTypeName)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([key, value], idx) => (
|
.map(([key, value], idx) => (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 * as ScreenOrientation from "@/packages/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";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { Platform } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
@@ -27,166 +27,171 @@ export default function IndexLayout() {
|
|||||||
},
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () =>
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
<DropdownMenu.Root>
|
!Platform.isTV && (
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Root>
|
||||||
<Ionicons
|
<DropdownMenu.Trigger>
|
||||||
name="ellipsis-horizontal-outline"
|
<Ionicons
|
||||||
size={24}
|
name="ellipsis-horizontal-outline"
|
||||||
color="white"
|
size={24}
|
||||||
/>
|
color="white"
|
||||||
</DropdownMenu.Trigger>
|
/>
|
||||||
<DropdownMenu.Content
|
</DropdownMenu.Trigger>
|
||||||
align={"end"}
|
<DropdownMenu.Content
|
||||||
alignOffset={-10}
|
align={"end"}
|
||||||
avoidCollisions={false}
|
alignOffset={-10}
|
||||||
collisionPadding={0}
|
avoidCollisions={false}
|
||||||
loop={false}
|
collisionPadding={0}
|
||||||
side={"bottom"}
|
loop={false}
|
||||||
sideOffset={10}
|
side={"bottom"}
|
||||||
>
|
sideOffset={10}
|
||||||
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label>
|
>
|
||||||
<DropdownMenu.Group key="display-group">
|
<DropdownMenu.Label>
|
||||||
<DropdownMenu.Sub>
|
{t("library.options.display")}
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
</DropdownMenu.Label>
|
||||||
{t("library.options.display")}
|
<DropdownMenu.Group key="display-group">
|
||||||
</DropdownMenu.SubTrigger>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
alignOffset={-10}
|
{t("library.options.display")}
|
||||||
avoidCollisions={true}
|
</DropdownMenu.SubTrigger>
|
||||||
collisionPadding={0}
|
<DropdownMenu.SubContent
|
||||||
loop={true}
|
alignOffset={-10}
|
||||||
sideOffset={10}
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-1"
|
||||||
|
value={settings.libraryOptions.display === "row"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "row",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-1">
|
||||||
|
{t("library.options.row")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-2"
|
||||||
|
value={settings.libraryOptions.display === "list"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "list",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-2">
|
||||||
|
{t("library.options.list")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
|
{t("library.options.image_style")}
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="poster-option"
|
||||||
|
value={
|
||||||
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
|
}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="poster-title">
|
||||||
|
{t("library.options.poster")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="cover-option"
|
||||||
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="cover-title">
|
||||||
|
{t("library.options.cover")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Group key="show-titles-group">
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
|
key="show-titles-option"
|
||||||
|
value={settings.libraryOptions.showTitles}
|
||||||
|
onValueChange={(newValue: string) => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: newValue === "on" ? true : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.ItemIndicator />
|
||||||
key="display-option-1"
|
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||||
value={settings.libraryOptions.display === "row"}
|
{t("library.options.show_titles")}
|
||||||
onValueChange={() =>
|
</DropdownMenu.ItemTitle>
|
||||||
updateSettings({
|
</DropdownMenu.CheckboxItem>
|
||||||
libraryOptions: {
|
<DropdownMenu.CheckboxItem
|
||||||
...settings.libraryOptions,
|
key="show-stats-option"
|
||||||
display: "row",
|
value={settings.libraryOptions.showStats}
|
||||||
},
|
onValueChange={(newValue: string) => {
|
||||||
})
|
updateSettings({
|
||||||
}
|
libraryOptions: {
|
||||||
>
|
...settings.libraryOptions,
|
||||||
<DropdownMenu.ItemIndicator />
|
showStats: newValue === "on" ? true : false,
|
||||||
<DropdownMenu.ItemTitle key="display-title-1">
|
},
|
||||||
{t("library.options.row")}
|
});
|
||||||
</DropdownMenu.ItemTitle>
|
}}
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="display-option-2"
|
|
||||||
value={settings.libraryOptions.display === "list"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "list",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="display-title-2">
|
|
||||||
{t("library.options.list")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
|
||||||
{t("library.options.image_style")}
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.ItemIndicator />
|
||||||
key="poster-option"
|
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||||
value={settings.libraryOptions.imageStyle === "poster"}
|
{t("library.options.show_stats")}
|
||||||
onValueChange={() =>
|
</DropdownMenu.ItemTitle>
|
||||||
updateSettings({
|
</DropdownMenu.CheckboxItem>
|
||||||
libraryOptions: {
|
</DropdownMenu.Group>
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "poster",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
|
||||||
{t("library.options.poster")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="cover-option"
|
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "cover",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="cover-title">
|
|
||||||
{t("library.options.cover")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Group key="show-titles-group">
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
|
||||||
key="show-titles-option"
|
|
||||||
value={settings.libraryOptions.showTitles}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
|
||||||
return;
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showTitles: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
|
||||||
{t("library.options.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">
|
|
||||||
{t("library.options.show_stats")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.defaultVideoOrientation) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (settings.autoRotate === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ 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 { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
const downloadProvider = !Platform.isTV
|
||||||
|
? require("@/providers/DownloadProvider")
|
||||||
|
: null;
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -52,6 +55,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
|
console.log("Direct Player");
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -67,8 +71,11 @@ export default function page() {
|
|||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
let getDownloadedItem = null;
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
getDownloadedItem = downloadProvider.useDownload();
|
||||||
|
}
|
||||||
|
|
||||||
const { getDownloadedItem } = useDownload();
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -109,8 +116,8 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (offline) {
|
if (offline && !Platform.isTV) {
|
||||||
const item = await getDownloadedItem(itemId);
|
const item = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (item) return item.item;
|
if (item) return item.item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +139,8 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (offline) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return null;
|
if (!data?.mediaSource) return null;
|
||||||
|
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
@@ -297,9 +304,6 @@ 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,
|
||||||
@@ -380,16 +384,18 @@ export default function page() {
|
|||||||
|
|
||||||
const allSubs =
|
const allSubs =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(sub) => sub.Type === "Subtitle"
|
(sub: { Type: string }) => sub.Type === "Subtitle"
|
||||||
) || [];
|
) || [];
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
(sub) => sub.Index === subtitleIndex
|
(sub: { Index: number }) => sub.Index === subtitleIndex
|
||||||
);
|
);
|
||||||
const allAudio =
|
const allAudio =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(audio) => audio.Type === "Audio"
|
(audio: { Type: string }) => audio.Type === "Audio"
|
||||||
) || [];
|
) || [];
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find(
|
||||||
|
(audio: { Index: number | undefined }) => audio.Index === audioIndex
|
||||||
|
);
|
||||||
|
|
||||||
// Direct playback CASE
|
// Direct playback CASE
|
||||||
if (!bitrateValue) {
|
if (!bitrateValue) {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Player = () => {
|
const Player = () => {
|
||||||
|
console.log("Transcoding Player");
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
@@ -295,9 +297,6 @@ const Player = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay: togglePlay,
|
||||||
|
|||||||
480
app/_layout.tsx
480
app/_layout.tsx
@@ -1,5 +1,7 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -8,8 +10,11 @@ import {
|
|||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
|
import {
|
||||||
|
SplashScreenProvider,
|
||||||
|
useSplashScreenLoading,
|
||||||
|
} from "@/providers/SplashScreenProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
import { LogProvider, writeToLog } from "@/utils/log";
|
import { LogProvider, writeToLog } from "@/utils/log";
|
||||||
@@ -18,64 +23,65 @@ 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
const BackGroundDownloader = !Platform.isTV
|
||||||
checkForExistingDownloads,
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
completeHandler,
|
: null;
|
||||||
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";
|
const BackgroundFetch = !Platform.isTV
|
||||||
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
import * as FileSystem from "expo-file-system";
|
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";
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { router, Stack } from "expo-router";
|
import { router, Stack } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
import * as TaskManager from "expo-task-manager";
|
import { getLocales } from "expo-localization";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Appearance, AppState, TouchableOpacity } from "react-native";
|
import { I18nextProvider, useTranslation } from "react-i18next";
|
||||||
|
import { Appearance, AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { I18nextProvider, useTranslation } from "react-i18next";
|
|
||||||
import i18n from "@/i18n";
|
|
||||||
import { getLocales } from "expo-localization";
|
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
if (!Platform.isTV) {
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
Notifications.setNotificationHandler({
|
handleNotification: async () => ({
|
||||||
handleNotification: async () => ({
|
shouldShowAlert: true,
|
||||||
shouldShowAlert: true,
|
shouldPlaySound: true,
|
||||||
shouldPlaySound: true,
|
shouldSetBadge: false,
|
||||||
shouldSetBadge: false,
|
}),
|
||||||
}),
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
function redirect(notification: Notifications.Notification) {
|
function redirect(notification: typeof Notifications.Notification) {
|
||||||
const url = notification.request.content.data?.url;
|
const url = notification.request.content.data?.url;
|
||||||
if (url) {
|
if (url) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
Notifications.getLastNotificationResponseAsync().then(
|
||||||
if (!isMounted || !response?.notification) {
|
(response: { notification: any }) => {
|
||||||
return;
|
if (!isMounted || !response?.notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirect(response?.notification);
|
||||||
}
|
}
|
||||||
redirect(response?.notification);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
(response) => {
|
(response: { notification: any }) => {
|
||||||
redirect(response.notification);
|
redirect(response.notification);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -87,99 +93,101 @@ function useNotificationObserver() {
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
if (!Platform.isTV) {
|
||||||
console.log("TaskManager ~ trigger");
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const deviceId = getOrSetDeviceId();
|
const deviceId = getOrSetDeviceId();
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
|
|
||||||
for (let job of jobs) {
|
for (let job of jobs) {
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
const downloadUrl = url + "download/" + job.id;
|
const downloadUrl = url + "download/" + job.id;
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task) => task.id === job.id)) {
|
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackGroundDownloader.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);
|
||||||
|
BackGroundDownloader.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: any) => {
|
||||||
|
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download failed",
|
||||||
|
data: {
|
||||||
|
url: `/downloads`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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()}`);
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
// Be sure to return the successful result type!
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -213,28 +221,20 @@ const checkAndRequestPermissions = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loaded) {
|
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [loaded]);
|
|
||||||
|
|
||||||
Appearance.setColorScheme("dark");
|
Appearance.setColorScheme("dark");
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JotaiProvider>
|
<SplashScreenProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Layout />
|
<JotaiProvider>
|
||||||
</I18nextProvider>
|
<ActionSheetProvider>
|
||||||
</JotaiProvider>
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<Layout />
|
||||||
|
</I18nextProvider>
|
||||||
|
</ActionSheetProvider>
|
||||||
|
</JotaiProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</SplashScreenProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,26 +251,8 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
const appState = useRef(AppState.currentState);
|
||||||
|
|
||||||
useKeepAwake();
|
|
||||||
useNotificationObserver();
|
|
||||||
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAndRequestPermissions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate === true)
|
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
|
||||||
else
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -278,112 +260,120 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings?.preferedLanguage, i18n]);
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
const appState = useRef(AppState.currentState);
|
if (!Platform.isTV) {
|
||||||
|
useKeepAwake();
|
||||||
|
useNotificationObserver();
|
||||||
|
|
||||||
useEffect(() => {
|
const { i18n } = useTranslation();
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
||||||
if (
|
useEffect(() => {
|
||||||
appState.current.match(/inactive|background/) &&
|
checkAndRequestPermissions();
|
||||||
nextAppState === "active"
|
}, []);
|
||||||
) {
|
|
||||||
checkForExistingDownloads();
|
useEffect(() => {
|
||||||
|
// If the user has auto rotate enabled, unlock the orientation
|
||||||
|
if (settings.autoRotate === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
}, [settings]);
|
||||||
|
|
||||||
checkForExistingDownloads();
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
(nextAppState) => {
|
||||||
|
if (
|
||||||
|
appState.current.match(/inactive|background/) &&
|
||||||
|
nextAppState === "active"
|
||||||
|
) {
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
subscription.remove();
|
||||||
(event) => {
|
};
|
||||||
setOrientation(event.orientationInfo.orientation);
|
}, []);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
const [loaded] = useFonts({
|
||||||
setOrientation(initialOrientation);
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
useSplashScreenLoading(!loaded);
|
||||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const url = Linking.useURL();
|
if (!loaded) {
|
||||||
|
return null;
|
||||||
if (url) {
|
|
||||||
const { hostname, path, queryParams } = Linking.parse(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobQueueProvider>
|
||||||
<ActionSheetProvider>
|
<JellyfinProvider>
|
||||||
<JobQueueProvider>
|
<PlaySettingsProvider>
|
||||||
<JellyfinProvider>
|
<LogProvider>
|
||||||
<PlaySettingsProvider>
|
<WebSocketProvider>
|
||||||
<LogProvider>
|
<DownloadProvider>
|
||||||
<WebSocketProvider>
|
<BottomSheetModalProvider>
|
||||||
<DownloadProvider>
|
<SystemBars style="light" hidden={false} />
|
||||||
<BottomSheetModalProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<SystemBars style="light" hidden={false} />
|
<Stack>
|
||||||
<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="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>
|
</DownloadProvider>
|
||||||
</BottomSheetModalProvider>
|
</WebSocketProvider>
|
||||||
</DownloadProvider>
|
</LogProvider>
|
||||||
</WebSocketProvider>
|
</PlaySettingsProvider>
|
||||||
</LogProvider>
|
</JellyfinProvider>
|
||||||
</PlaySettingsProvider>
|
</JobQueueProvider>
|
||||||
</JellyfinProvider>
|
</QueryClientProvider>
|
||||||
</JobQueueProvider>
|
|
||||||
</ActionSheetProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
@@ -39,7 +40,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.audio")}</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.audio")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text className="" numberOfLines={1}>
|
<Text className="" numberOfLines={1}>
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -54,6 +54,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
@@ -77,7 +78,9 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.quality")}</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.quality")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity 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}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
@@ -18,12 +17,12 @@ interface Props extends ViewProps {
|
|||||||
background?: "blur" | "transparent";
|
background?: "blur" | "transparent";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Chromecast: React.FC<Props> = ({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) {
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
@@ -83,4 +82,4 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
0
components/Chromecast.tv.tsx
Normal file
0
components/Chromecast.tv.tsx
Normal file
1
components/ContextMenu.ts
Normal file
1
components/ContextMenu.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "zeego/context-menu";
|
||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
@@ -3,6 +3,7 @@ import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
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";
|
||||||
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
@@ -24,12 +25,12 @@ 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 * as ScreenOrientation from "@/packages/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 { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
@@ -81,23 +82,29 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
useEffect(() => {
|
||||||
headerRight: () =>
|
navigation.setOptions({
|
||||||
item && (
|
headerRight: () =>
|
||||||
<View className="flex flex-row items-center space-x-2">
|
item && (
|
||||||
<Chromecast background="blur" width={22} height={22} />
|
<View className="flex flex-row items-center space-x-2">
|
||||||
{item.Type !== "Program" && (
|
<Chromecast.Chromecast
|
||||||
<View className="flex flex-row items-center space-x-2">
|
background="blur"
|
||||||
<DownloadSingleItem item={item} size="large" />
|
width={22}
|
||||||
<PlayedStatus item={item} />
|
height={22}
|
||||||
<AddToFavorites item={item} type="item" />
|
/>
|
||||||
</View>
|
{item.Type !== "Program" && (
|
||||||
)}
|
<View className="flex flex-row items-center space-x-2">
|
||||||
</View>
|
<DownloadSingleItem item={item} size="large" />
|
||||||
),
|
<PlayedStatus items={[item]} size="large" />
|
||||||
});
|
<AddToFavorites item={item} type="item" />
|
||||||
}, [item]);
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
@@ -189,9 +196,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col bg-transparent shrink">
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
|
{/* {!Platform.isTV && ( */}
|
||||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
<ItemHeader item={item} className="mb-4" />
|
<ItemHeader item={item} className="mb-4" />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
@@ -247,11 +255,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* {!Platform.isTV && ( */}
|
||||||
<PlayButton
|
<PlayButton
|
||||||
className="grow"
|
className="grow"
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
/>
|
/>
|
||||||
|
{/* )} */}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
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(
|
||||||
@@ -61,7 +62,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.video")}</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.video")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
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";
|
||||||
@@ -31,7 +32,9 @@ 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";
|
const chromecastProfile = !Platform.isTV
|
||||||
|
? require("@/utils/profiles/chromecast")
|
||||||
|
: null;
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
@@ -114,99 +117,101 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
if (!Platform.isTV) {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
await CastContext.getPlayServicesState().then(async (state) => {
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
else {
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
// Get a new URL with the Chromecast device profile:
|
else {
|
||||||
const data = await getStreamUrl({
|
// Get a new URL with the Chromecast device profile:
|
||||||
api,
|
const data = await getStreamUrl({
|
||||||
item,
|
api,
|
||||||
deviceProfile: chromecastProfile,
|
item,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
deviceProfile: chromecastProfile,
|
||||||
userId: user?.Id,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
userId: user?.Id,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
});
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
|
|
||||||
if (!data?.url) {
|
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
|
||||||
Alert.alert(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("player.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();
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.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;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
|
|||||||
251
components/PlayButton.tv.tsx
Normal file
251
components/PlayButton.tv.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
interpolate,
|
||||||
|
interpolateColor,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { SelectedOptions } from "./ItemContent";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 500;
|
||||||
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
|
export const PlayButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const startWidth = useSharedValue(0);
|
||||||
|
const targetWidth = useSharedValue(0);
|
||||||
|
const endColor = useSharedValue(colorAtom);
|
||||||
|
const startColor = useSharedValue(colorAtom);
|
||||||
|
const widthProgress = useSharedValue(0);
|
||||||
|
const colorChangeProgress = useSharedValue(0);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const goToPlayer = useCallback(
|
||||||
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
|
if (!bitrateValue) {
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/player/transcoding-player?${q}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPress = useCallback(async () => {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = queryParams.toString();
|
||||||
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
|
return;
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
settings,
|
||||||
|
api,
|
||||||
|
user,
|
||||||
|
router,
|
||||||
|
showActionSheetWithOptions,
|
||||||
|
selectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
|
const userData = item.UserData;
|
||||||
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
? Math.max(
|
||||||
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
|
MIN_PLAYBACK_WIDTH
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => derivedTargetWidth.value,
|
||||||
|
(newWidth) => {
|
||||||
|
targetWidth.value = newWidth;
|
||||||
|
widthProgress.value = 0;
|
||||||
|
widthProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => colorAtom,
|
||||||
|
(newColor) => {
|
||||||
|
endColor.value = newColor;
|
||||||
|
colorChangeProgress.value = 0;
|
||||||
|
colorChangeProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[colorAtom]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout_2 = setTimeout(() => {
|
||||||
|
startColor.value = colorAtom;
|
||||||
|
startWidth.value = targetWidth.value;
|
||||||
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout_2);
|
||||||
|
};
|
||||||
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ANIMATED STYLES
|
||||||
|
*/
|
||||||
|
const animatedAverageStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedWidthStyle = useAnimatedStyle(() => ({
|
||||||
|
width: `${interpolate(
|
||||||
|
widthProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startWidth.value, targetWidth.value]
|
||||||
|
)}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||||
|
color: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.text, endColor.value.text]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
/**
|
||||||
|
* *********************
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!item}
|
||||||
|
accessibilityLabel="Play button"
|
||||||
|
accessibilityHint="Tap to play the media"
|
||||||
|
onPress={onPress}
|
||||||
|
className={`relative`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorAtom.primary,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="vlc"
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* <View className="mt-2 flex flex-row items-center">
|
||||||
|
<Ionicons
|
||||||
|
name="information-circle"
|
||||||
|
size={12}
|
||||||
|
className=""
|
||||||
|
color={"#9BA1A6"}
|
||||||
|
/>
|
||||||
|
<Text className="text-neutral-500 ml-1">
|
||||||
|
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||||
|
</Text>
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,16 +6,19 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
items: BaseItemDto[];
|
||||||
|
size?: "default" | "large";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
queryClient.invalidateQueries({
|
items.forEach((item) => {
|
||||||
queryKey: ["item", item.Id],
|
queryClient.invalidateQueries({
|
||||||
});
|
queryKey: ["item", item.Id],
|
||||||
|
});
|
||||||
|
})
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems"],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
@@ -39,15 +42,20 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
|
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
fillColor={allPlayed ? "primary" : undefined}
|
||||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
onPress={async () => {
|
||||||
size="large"
|
console.log(allPlayed);
|
||||||
|
await markAsPlayedStatus(!allPlayed)
|
||||||
|
}}
|
||||||
|
size={props.size}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,6 +21,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
isTranscoding,
|
isTranscoding,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||||
|
|
||||||
@@ -51,7 +52,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col " {...props}>
|
<View className="flex flex-col " {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.subtitles")}</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text className=" ">
|
<Text className=" ">
|
||||||
{selectedSubtitleSteam
|
{selectedSubtitleSteam
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import {Text} from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
data: T[]
|
data: T[];
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
placeholderText?: string,
|
placeholderText?: string;
|
||||||
keyExtractor: (item: T) => string
|
keyExtractor: (item: T) => string;
|
||||||
titleExtractor: (item: T) => string | undefined
|
titleExtractor: (item: T) => string | undefined;
|
||||||
title: string | ReactNode,
|
title: string | ReactNode;
|
||||||
label: string,
|
label: string;
|
||||||
onSelected: (...item: T[]) => void
|
onSelected: (...item: T[]) => void;
|
||||||
multi?: boolean
|
multi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dropdown = <T extends unknown>({
|
const Dropdown = <T extends unknown>({
|
||||||
data,
|
data,
|
||||||
disabled,
|
disabled,
|
||||||
placeholderText,
|
placeholderText,
|
||||||
@@ -28,38 +33,32 @@ const Dropdown = <T extends unknown>({
|
|||||||
multi = false,
|
multi = false,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const [selected, setSelected] = useState<T[]>();
|
const [selected, setSelected] = useState<T[]>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selected !== undefined) {
|
if (selected !== undefined) {
|
||||||
onSelected(...selected)
|
onSelected(...selected);
|
||||||
}
|
}
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
||||||
disabled={disabled === true}
|
|
||||||
showText={false}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
{typeof title === 'string' ? (
|
{typeof title === "string" ? (
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className="opacity-50 mb-1 text-xs">{title}</Text>
|
||||||
{title}
|
<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>
|
|
||||||
<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}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
|
{selected?.length !== undefined
|
||||||
|
? selected.map(titleExtractor).join(",")
|
||||||
|
: placeholderText}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>{title}</>
|
||||||
{title}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -72,37 +71,48 @@ const Dropdown = <T extends unknown>({
|
|||||||
sideOffset={0}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||||
{data.map((item, idx) => (
|
{data.map((item, idx) =>
|
||||||
multi ? (
|
multi ? (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
value={selected?.some(s => keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
|
value={
|
||||||
key={keyExtractor(item)}
|
selected?.some((s) => keyExtractor(s) == keyExtractor(item))
|
||||||
onValueChange={(next, previous) =>
|
? "on"
|
||||||
setSelected((p) => {
|
: "off"
|
||||||
const prev = p || []
|
}
|
||||||
if (next == 'on') {
|
key={keyExtractor(item)}
|
||||||
return [...prev, item]
|
onValueChange={(next, previous) =>
|
||||||
}
|
setSelected((p) => {
|
||||||
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
|
const prev = p || [];
|
||||||
})
|
if (next == "on") {
|
||||||
}
|
return [...prev, item];
|
||||||
>
|
}
|
||||||
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
|
return [
|
||||||
</DropdownMenu.CheckboxItem>
|
...prev.filter(
|
||||||
)
|
(p) => keyExtractor(p) !== keyExtractor(item)
|
||||||
: (
|
),
|
||||||
<DropdownMenu.Item
|
];
|
||||||
key={keyExtractor(item)}
|
})
|
||||||
onSelect={() => setSelected([item])}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
{titleExtractor(item)}
|
||||||
)
|
</DropdownMenu.ItemTitle>
|
||||||
))}
|
</DropdownMenu.CheckboxItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
onSelect={() => setSelected([item])}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{titleExtractor(item)}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dropdown;
|
export default Dropdown;
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextInput, TextInputProps } from "react-native";
|
import {Platform, TextInput, TextInputProps, TouchableOpacity} from "react-native";
|
||||||
export function Input(props: TextInputProps) {
|
export function Input(props: TextInputProps) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
const inputRef = React.useRef<TextInput>(null);
|
const inputRef = React.useRef<TextInput>(null);
|
||||||
|
|
||||||
return (
|
return Platform.isTV ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onFocus={() => inputRef?.current?.focus?.()}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[{ color: "white" }, style]}
|
||||||
|
placeholderTextColor={"#9CA3AF"}
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="p-4 rounded-xl bg-neutral-900"
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
@@ -14,5 +28,5 @@ export function Input(props: TextInputProps) {
|
|||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
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 * as ContextMenu from "@/components/ContextMenu";
|
||||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
import {
|
||||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
hasPermission,
|
||||||
|
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,26 +29,27 @@ 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 (
|
||||||
@@ -55,7 +59,16 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
router.push({
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
|
params: {
|
||||||
|
...result,
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest,
|
||||||
|
posterSrc,
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -71,31 +84,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
>
|
>
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||||
{canRequest && result.mediaType === MediaType.MOVIE && (
|
{canRequest && result.mediaType === MediaType.MOVIE && (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="item-1"
|
key="item-1"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (autoApprove) {
|
if (autoApprove) {
|
||||||
request()
|
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",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
shouldDismissMenuOnSelect
|
androidIconName="download"
|
||||||
>
|
/>
|
||||||
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
|
</ContextMenu.Item>
|
||||||
<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.Content>
|
||||||
</ContextMenu.Root>
|
</ContextMenu.Root>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import {
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren, useCallback } from "react";
|
import { PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -57,7 +56,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
@@ -75,10 +74,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
async (selectedIndex) => {
|
async (selectedIndex) => {
|
||||||
if (selectedIndex === 0) {
|
if (selectedIndex === 0) {
|
||||||
await markAsPlayedStatus(true);
|
await markAsPlayedStatus(true);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
} else if (selectedIndex === 1) {
|
} else if (selectedIndex === 1) {
|
||||||
await markAsPlayedStatus(false);
|
await markAsPlayedStatus(false);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
|||||||
import { JobStatus } from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
const BackGroundDownloader = !Platform.isTV
|
||||||
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
|
: null;
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -38,7 +42,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
|
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
|
||||||
<View className="space-y-2">
|
<View className="space-y-2">
|
||||||
{processes?.map((p) => (
|
{processes?.map((p: JobStatus) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -63,7 +67,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||||
try {
|
try {
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.id === id) {
|
if (task.id === id) {
|
||||||
task.stop();
|
task.stop();
|
||||||
@@ -76,8 +80,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FFmpegKit.cancel(Number(id));
|
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
setProcesses((prev: any[]) => prev.filter((p: { id: string; }) => p.id !== id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
|
|||||||
},
|
},
|
||||||
onRequested
|
onRequested
|
||||||
)
|
)
|
||||||
}, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
|
}, [modalRequestProps, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
|
||||||
|
|
||||||
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
|
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
|||||||
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
|
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
|
||||||
id={item.id.toString()}
|
id={item.id.toString()}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
colors={[]}
|
colors={['transparent', 'transparent']}
|
||||||
contentFit={"cover"}
|
contentFit={"cover"}
|
||||||
url={jellyseerrApi?.imageProxy(
|
url={jellyseerrApi?.imageProxy(
|
||||||
item.backdrops?.[0],
|
item.backdrops?.[0],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
state,
|
state,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
const keys = useMemo<SeasonKeys>(
|
const keys = useMemo<SeasonKeys>(
|
||||||
() =>
|
() =>
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
@@ -92,7 +94,9 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-row">
|
<View className="flex flex-row">
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text>{t("item_card.season")} {seasonIndex}</Text>
|
<Text>
|
||||||
|
{t("item_card.season")} {seasonIndex}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
SeasonIndexState,
|
SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { PlayedStatus } from "../PlayedStatus";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
initialSeasonIndex?: number;
|
initialSeasonIndex?: number;
|
||||||
@@ -145,17 +147,20 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{episodes?.length || 0 > 0 ? (
|
{episodes?.length || 0 > 0 ? (
|
||||||
<DownloadItems
|
<View className="flex flex-row items-center space-x-2">
|
||||||
title={t("item_card.download.download_season")}
|
<DownloadItems
|
||||||
className="ml-2"
|
title={t("item_card.download.download_season")}
|
||||||
items={episodes || []}
|
className="ml-2"
|
||||||
MissingDownloadIconComponent={() => (
|
items={episodes || []}
|
||||||
<Ionicons name="download" size={20} color="white" />
|
MissingDownloadIconComponent={() => (
|
||||||
)}
|
<Ionicons name="download" size={20} color="white" />
|
||||||
DownloadedIconComponent={() => (
|
)}
|
||||||
<Ionicons name="download" size={20} color="#9333ea" />
|
DownloadedIconComponent={() => (
|
||||||
)}
|
<Ionicons name="download" size={20} color="#9333ea" />
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
<PlayedStatus items={episodes || []} />
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className="px-4 flex flex-col mt-4">
|
<View className="px-4 flex flex-col mt-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -10,6 +10,7 @@ import { APP_LANGUAGES } from "@/i18n";
|
|||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -17,60 +18,58 @@ export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ListGroup
|
<ListGroup title={t("home.settings.languages.title")}>
|
||||||
title={t("home.settings.languages.title")}
|
|
||||||
>
|
|
||||||
<ListItem title={t("home.settings.languages.app_language")}>
|
<ListItem title={t("home.settings.languages.app_language")}>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text>
|
<Text>
|
||||||
{APP_LANGUAGES.find(
|
{APP_LANGUAGES.find(
|
||||||
(l) => l.value === settings?.preferedLanguage
|
(l) => l.value === settings?.preferedLanguage
|
||||||
)?.label || t("home.settings.languages.system")}
|
)?.label || t("home.settings.languages.system")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={true}
|
loop={true}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="start"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("home.settings.languages.title")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"unknown"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
preferedLanguage: undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.Label>
|
||||||
{t("home.settings.languages.system")}
|
{t("home.settings.languages.title")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.Label>
|
||||||
</DropdownMenu.Item>
|
|
||||||
{APP_LANGUAGES?.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={l?.value ?? "unknown"}
|
key={"unknown"}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
preferedLanguage: l.value,
|
preferedLanguage: undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.languages.system")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
{APP_LANGUAGES?.map((l) => (
|
||||||
</DropdownMenu.Content>
|
<DropdownMenu.Item
|
||||||
</DropdownMenu.Root>
|
key={l?.value ?? "unknown"}
|
||||||
</ListItem>
|
onSelect={() => {
|
||||||
</ListGroup>
|
updateSettings({
|
||||||
</View>
|
preferedLanguage: l.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
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";
|
||||||
@@ -7,11 +7,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {useSettings} from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const [_, __, pluginSettings] = useSettings();
|
const [_, __, pluginSettings] = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
@@ -47,7 +48,8 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
|
{settings?.defaultAudioLanguage?.DisplayName ||
|
||||||
|
t("home.settings.audio.none")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-expand-sharp"
|
name="chevron-expand-sharp"
|
||||||
@@ -65,7 +67,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
|
{t("home.settings.audio.language")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={"none-audio"}
|
key={"none-audio"}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -74,7 +78,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{t("home.settings.audio.none")}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.audio.none")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{cultures?.map((l) => (
|
{cultures?.map((l) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Switch, TouchableOpacity } from "react-native";
|
import { Platform, Switch, TouchableOpacity } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export const DownloadSettings: React.FC = ({ ...props }) => {
|
export default function DownloadSettings({ ...props }) {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const { setProcesses } = useDownload();
|
const { setProcesses } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -61,7 +61,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{t("home.settings.downloads.methods")}</DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
|
{t("home.settings.downloads.methods")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="1"
|
key="1"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -69,7 +71,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
setProcesses([]);
|
setProcesses([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{t("home.settings.downloads.default")}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.downloads.default")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="2"
|
key="2"
|
||||||
@@ -79,7 +83,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{t("home.settings.downloads.optimized")}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.downloads.optimized")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
@@ -134,4 +140,4 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
@@ -5,10 +6,12 @@ import {
|
|||||||
unregisterBackgroundFetchAsync,
|
unregisterBackgroundFetchAsync,
|
||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
const BackgroundFetch = !Platform.isTV
|
||||||
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import React, { useEffect, useMemo } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { Linking, Switch, TouchableOpacity } from "react-native";
|
import { Linking, Switch, TouchableOpacity } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -29,6 +32,8 @@ export const OtherSettings: React.FC = () => {
|
|||||||
* Background task
|
* Background task
|
||||||
*******************/
|
*******************/
|
||||||
const checkStatusAsync = async () => {
|
const checkStatusAsync = async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
await BackgroundFetch.getStatusAsync();
|
await BackgroundFetch.getStatusAsync();
|
||||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,16 +49,25 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
});
|
});
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
|
Alert.alert(
|
||||||
|
t("home.settings.quick_connect.success"),
|
||||||
|
t("home.settings.quick_connect.quick_connect_autorized")
|
||||||
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
Alert.alert(
|
||||||
|
t("home.settings.quick_connect.error"),
|
||||||
|
t("home.settings.quick_connect.invalid_code")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
Alert.alert(
|
||||||
|
t("home.settings.quick_connect.error"),
|
||||||
|
t("home.settings.quick_connect.invalid_code")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
@@ -96,7 +105,9 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode="always"
|
clearButtonMode="always"
|
||||||
placeholder={t("home.settings.quick_connect.enter_the_quick_connect_code")}
|
placeholder={t(
|
||||||
|
"home.settings.quick_connect.enter_the_quick_connect_code"
|
||||||
|
)}
|
||||||
placeholderTextColor="#9CA3AF"
|
placeholderTextColor="#9CA3AF"
|
||||||
value={quickConnectCode}
|
value={quickConnectCode}
|
||||||
onChangeText={setQuickConnectCode}
|
onChangeText={setQuickConnectCode}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export const StorageSettings = () => {
|
|||||||
<Text className="">{t("home.settings.storage.storage_title")}</Text>
|
<Text className="">{t("home.settings.storage.storage_title")}</Text>
|
||||||
{size && (
|
{size && (
|
||||||
<Text className="text-neutral-500">
|
<Text className="text-neutral-500">
|
||||||
{t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
|
{t("home.settings.storage.size_used", {
|
||||||
|
used: Number(size.total - size.remaining).bytesToReadable(),
|
||||||
|
total: size.total?.bytesToReadable(),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -79,13 +82,20 @@ export const StorageSettings = () => {
|
|||||||
<View className="flex flex-row items-center">
|
<View className="flex flex-row items-center">
|
||||||
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
|
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
|
||||||
<Text className="text-white text-xs">
|
<Text className="text-white text-xs">
|
||||||
{t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
|
{t("home.settings.storage.app_usage", {
|
||||||
|
usedSpace: calculatePercentage(size.app, size.total),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center">
|
<View className="flex flex-row items-center">
|
||||||
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
|
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
|
||||||
<Text className="text-white text-xs">
|
<Text className="text-white text-xs">
|
||||||
{t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
|
{t("home.settings.storage.device_usage", {
|
||||||
|
availableSpace: calculatePercentage(
|
||||||
|
size.total - size.remaining - size.app,
|
||||||
|
size.total
|
||||||
|
),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
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";
|
||||||
@@ -8,13 +8,14 @@ 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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {useSettings} from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {Stepper} from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import Dropdown from "@/components/common/Dropdown";
|
import Dropdown from "@/components/common/Dropdown";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const [_, __, pluginSettings] = useSettings();
|
const [_, __, pluginSettings] = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
@@ -34,7 +35,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const subtitleModeKeys = {
|
const subtitleModeKeys = {
|
||||||
[SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
|
[SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
|
||||||
[SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
|
[SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
|
||||||
[SubtitlePlaybackMode.OnlyForced]: "home.settings.subtitles.modes.OnlyForced",
|
[SubtitlePlaybackMode.OnlyForced]:
|
||||||
|
"home.settings.subtitles.modes.OnlyForced",
|
||||||
[SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
|
[SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
|
||||||
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
|
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
|
||||||
};
|
};
|
||||||
@@ -51,13 +53,22 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
>
|
>
|
||||||
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
data={[{DisplayName: t("home.settings.subtitles.none"), ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
|
data={[
|
||||||
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
|
{
|
||||||
|
DisplayName: t("home.settings.subtitles.none"),
|
||||||
|
ThreeLetterISOLanguageName: "none-subs",
|
||||||
|
},
|
||||||
|
...(cultures ?? []),
|
||||||
|
]}
|
||||||
|
keyExtractor={(item) =>
|
||||||
|
item?.ThreeLetterISOLanguageName ?? "unknown"
|
||||||
|
}
|
||||||
titleExtractor={(item) => item?.DisplayName}
|
titleExtractor={(item) => item?.DisplayName}
|
||||||
title={
|
title={
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
|
{settings?.defaultSubtitleLanguage?.DisplayName ||
|
||||||
|
t("home.settings.subtitles.none")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-expand-sharp"
|
name="chevron-expand-sharp"
|
||||||
@@ -69,11 +80,13 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
label={t("home.settings.subtitles.language")}
|
label={t("home.settings.subtitles.language")}
|
||||||
onSelected={(defaultSubtitleLanguage) =>
|
onSelected={(defaultSubtitleLanguage) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
|
defaultSubtitleLanguage:
|
||||||
? null
|
defaultSubtitleLanguage.DisplayName ===
|
||||||
: defaultSubtitleLanguage
|
t("home.settings.subtitles.none")
|
||||||
|
? null
|
||||||
|
: defaultSubtitleLanguage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -89,7 +102,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={
|
title={
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")}
|
{t(subtitleModeKeys[settings?.subtitleMode]) ||
|
||||||
|
t("home.settings.subtitles.loading")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-expand-sharp"
|
name="chevron-expand-sharp"
|
||||||
@@ -99,9 +113,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
label={t("home.settings.subtitles.subtitle_mode")}
|
label={t("home.settings.subtitles.subtitle_mode")}
|
||||||
onSelected={(subtitleMode) =>
|
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
|
||||||
updateSettings({subtitleMode})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -128,7 +140,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
step={5}
|
step={5}
|
||||||
min={0}
|
min={0}
|
||||||
max={120}
|
max={120}
|
||||||
onUpdate={(subtitleSize) => updateSettings({subtitleSize})}
|
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { View, StyleSheet } from "react-native";
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { VolumeManager } from "react-native-volume-manager";
|
// import { VolumeManager } from "react-native-volume-manager";
|
||||||
|
const VolumeManager = !Platform.isTV
|
||||||
|
? require("react-native-volume-manager")
|
||||||
|
: null;
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
interface AudioSliderProps {
|
interface AudioSliderProps {
|
||||||
@@ -10,6 +13,8 @@ interface AudioSliderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const volume = useSharedValue<number>(50); // Explicitly type as number
|
const volume = useSharedValue<number>(50); // Explicitly type as number
|
||||||
const min = useSharedValue<number>(0); // Explicitly type as number
|
const min = useSharedValue<number>(0); // Explicitly type as number
|
||||||
const max = useSharedValue<number>(100); // Explicitly type as number
|
const max = useSharedValue<number>(100); // Explicitly type as number
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { View, StyleSheet } from "react-native";
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import * as Brightness from "expo-brightness";
|
// import * as Brightness from "expo-brightness";
|
||||||
|
const Brightness = !Platform.isTV ? require("expo-brightness") : null;
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
|
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
|
||||||
|
|
||||||
const BrightnessSlider = () => {
|
const BrightnessSlider = () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const brightness = useSharedValue(50);
|
const brightness = useSharedValue(50);
|
||||||
const min = useSharedValue(0);
|
const min = useSharedValue(0);
|
||||||
const max = useSharedValue(100);
|
const max = useSharedValue(100);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
|
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { View, TouchableOpacity } from "react-native";
|
import { View, TouchableOpacity, Platform } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
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";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { View, TouchableOpacity } from "react-native";
|
import { View, TouchableOpacity, Platform } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
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";
|
||||||
|
|||||||
20
eas.json
20
eas.json
@@ -11,6 +11,16 @@
|
|||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"development_tv": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"EXPO_TV": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
@@ -33,6 +43,16 @@
|
|||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"production-apk-tv": {
|
||||||
|
"channel": "0.25.0",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk",
|
||||||
|
"image": "latest"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"EXPO_TV": "1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
const Haptics = !Platform.isTV ? require("expo-haptics") : null;
|
||||||
|
|
||||||
export type HapticFeedbackType =
|
export type HapticFeedbackType =
|
||||||
| "light"
|
| "light"
|
||||||
@@ -15,15 +15,21 @@ export type HapticFeedbackType =
|
|||||||
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
const createHapticHandler = useCallback(
|
const createHapticHandler = useCallback(
|
||||||
(type: Haptics.ImpactFeedbackStyle) => {
|
(type: typeof Haptics.ImpactFeedbackStyle) => {
|
||||||
return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type);
|
return Platform.OS === "web" || Platform.isTV
|
||||||
|
? () => {}
|
||||||
|
: () => Haptics.impactAsync(type);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const createNotificationFeedback = useCallback(
|
const createNotificationFeedback = useCallback(
|
||||||
(type: Haptics.NotificationFeedbackType) => {
|
(type: typeof Haptics.NotificationFeedbackType) => {
|
||||||
return Platform.OS === "web"
|
return Platform.OS === "web" || Platform.isTV
|
||||||
? () => {}
|
? () => {}
|
||||||
: () => Haptics.notificationAsync(type);
|
: () => Haptics.notificationAsync(type);
|
||||||
},
|
},
|
||||||
@@ -35,7 +41,10 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
|||||||
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
||||||
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
||||||
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
||||||
selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync,
|
selection:
|
||||||
|
Platform.OS === "web" || Platform.isTV
|
||||||
|
? () => {}
|
||||||
|
: Haptics.selectionAsync,
|
||||||
success: createNotificationFeedback(
|
success: createNotificationFeedback(
|
||||||
Haptics.NotificationFeedbackType.Success
|
Haptics.NotificationFeedbackType.Success
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { storage } from "@/utils/mmkv";
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { getColors } from "react-native-image-colors";
|
import { Platform } from "react-native";
|
||||||
|
// import { getColors } from "react-native-image-colors";
|
||||||
|
const Colors = !Platform.isTV ? require("react-native-image-colors") : null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to extract and manage image colors for a given item.
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
@@ -28,6 +30,8 @@ export const useImageColors = ({
|
|||||||
url?: string | null;
|
url?: string | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
@@ -62,11 +66,11 @@ export const useImageColors = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract colors from the image
|
// Extract colors from the image
|
||||||
getColors(source.uri, {
|
Colors.getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: false,
|
cache: false,
|
||||||
})
|
})
|
||||||
.then((colors) => {
|
.then((colors: { platform: string; dominant: string; vibrant: string; detail: string; primary: string; }) => {
|
||||||
let primary: string = "#fff";
|
let primary: string = "#fff";
|
||||||
let text: string = "#000";
|
let text: string = "#000";
|
||||||
let backup: string = "#fff";
|
let backup: string = "#fff";
|
||||||
@@ -100,7 +104,7 @@ export const useImageColors = ({
|
|||||||
storage.set(`${source.uri}-text`, text);
|
storage.set(`${source.uri}-text`, text);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error: any) => {
|
||||||
console.error("Error getting colors", error);
|
console.error("Error getting colors", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useHaptic } from "./useHaptic";
|
import { useHaptic } from "./useHaptic";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -14,7 +14,6 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
const queriesToInvalidate = [
|
const queriesToInvalidate = [
|
||||||
["item", item.Id],
|
|
||||||
["resumeItems"],
|
["resumeItems"],
|
||||||
["continueWatching"],
|
["continueWatching"],
|
||||||
["nextUp-all"],
|
["nextUp-all"],
|
||||||
@@ -24,6 +23,11 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
["home"],
|
["home"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if(!item.Id) return;
|
||||||
|
queriesToInvalidate.push(["item", item.Id]);
|
||||||
|
});
|
||||||
|
|
||||||
queriesToInvalidate.forEach((queryKey) => {
|
queriesToInvalidate.forEach((queryKey) => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
});
|
});
|
||||||
@@ -32,40 +36,8 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
const markAsPlayedStatus = async (played: boolean) => {
|
const markAsPlayedStatus = async (played: boolean) => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
// Optimistic update
|
items.forEach((item) => {
|
||||||
queryClient.setQueryData(
|
// Optimistic update
|
||||||
["item", item.Id],
|
|
||||||
(oldData: BaseItemDto | undefined) => {
|
|
||||||
if (oldData) {
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
UserData: {
|
|
||||||
...oldData.UserData,
|
|
||||||
Played: !played,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return oldData;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (played) {
|
|
||||||
await markAsNotPlayed({
|
|
||||||
api: api,
|
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await markAsPlayed({
|
|
||||||
api: api,
|
|
||||||
item: item,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
invalidateQueries();
|
|
||||||
} catch (error) {
|
|
||||||
// Revert optimistic update on error
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["item", item.Id],
|
["item", item.Id],
|
||||||
(oldData: BaseItemDto | undefined) => {
|
(oldData: BaseItemDto | undefined) => {
|
||||||
@@ -81,8 +53,45 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
return oldData;
|
return oldData;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process all items
|
||||||
|
await Promise.all(items.map(item =>
|
||||||
|
played
|
||||||
|
? markAsPlayed({ api, item, userId: user?.Id })
|
||||||
|
: markAsNotPlayed({ api, itemId: item?.Id, userId: user?.Id })
|
||||||
|
));
|
||||||
|
|
||||||
|
// Bulk invalidate
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [
|
||||||
|
"resumeItems",
|
||||||
|
"continueWatching",
|
||||||
|
"nextUp-all",
|
||||||
|
"nextUp",
|
||||||
|
"episodes",
|
||||||
|
"seasons",
|
||||||
|
"home",
|
||||||
|
...items.map(item => ["item", item.Id])
|
||||||
|
].flat()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Revert all optimistic updates on any failure
|
||||||
|
items.forEach(item => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["item", item.Id],
|
||||||
|
(oldData: BaseItemDto | undefined) =>
|
||||||
|
oldData ? {
|
||||||
|
...oldData,
|
||||||
|
UserData: { ...oldData.UserData, Played: played }
|
||||||
|
} : oldData
|
||||||
|
);
|
||||||
|
});
|
||||||
console.error("Error updating played status:", error);
|
console.error("Error updating played status:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateQueries();
|
||||||
};
|
};
|
||||||
|
|
||||||
return markAsPlayedStatus;
|
return markAsPlayedStatus;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export const useOrientation = () => {
|
export const useOrientation = () => {
|
||||||
const [orientation, setOrientation] = useState(
|
const [orientation, setOrientation] = useState(
|
||||||
ScreenOrientation.OrientationLock.UNKNOWN
|
Platform.isTV
|
||||||
|
? ScreenOrientation.OrientationLock.LANDSCAPE
|
||||||
|
: ScreenOrientation.OrientationLock.UNKNOWN
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (Platform.isTV) return { orientation, setOrientation };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const orientationSubscription =
|
const orientationSubscription =
|
||||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
ScreenOrientation.addOrientationChangeListener((event) => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export const useOrientationSettings = () => {
|
export const useOrientationSettings = () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
|
||||||
|
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||||
|
const FFMPEGKitReactNative = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -18,8 +20,12 @@ import useDownloadHelper from "@/utils/download";
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
|
||||||
|
type Statistics = typeof FFMPEGKitReactNative.Statistics
|
||||||
|
const FFmpegKit = FFMPEGKitReactNative.FFmpegKit;
|
||||||
const createFFmpegCommand = (url: string, output: string) => [
|
const createFFmpegCommand = (url: string, output: string) => [
|
||||||
"-y", // overwrite output files without asking
|
"-y", // overwrite output files without asking
|
||||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||||
@@ -55,7 +61,12 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload();
|
const {
|
||||||
|
saveDownloadedItemInfo,
|
||||||
|
setProcesses,
|
||||||
|
processes,
|
||||||
|
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||||
|
} = useDownload();
|
||||||
|
|
||||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||||
await saveSeriesPrimaryImage(item);
|
await saveSeriesPrimaryImage(item);
|
||||||
@@ -79,9 +90,9 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
const stat = await session.getLastReceivedStatistics();
|
const stat = await session.getLastReceivedStatistics();
|
||||||
await FileSystem.moveAsync({
|
await FileSystem.moveAsync({
|
||||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`
|
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
|
||||||
})
|
});
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["downloadedItems"],
|
queryKey: ["downloadedItems"],
|
||||||
});
|
});
|
||||||
@@ -89,8 +100,8 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
toast.success(t("home.downloads.toasts.download_completed"));
|
toast.success(t("home.downloads.toasts.download_completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcesses((prev) => {
|
setProcesses((prev: any[]) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -114,8 +125,8 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
||||||
|
|
||||||
if (!item.Id) throw new Error("Item is undefined");
|
if (!item.Id) throw new Error("Item is undefined");
|
||||||
setProcesses((prev) => {
|
setProcesses((prev: any[]) => {
|
||||||
return prev.map((process) => {
|
return prev.map((process: { itemId: string | undefined; }) => {
|
||||||
if (process.itemId === item.Id) {
|
if (process.itemId === item.Id) {
|
||||||
return {
|
return {
|
||||||
...process,
|
...process,
|
||||||
@@ -133,12 +144,16 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
const cacheDir = await FileSystem.getInfoAsync(
|
||||||
|
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||||
|
);
|
||||||
if (!cacheDir.exists) {
|
if (!cacheDir.exists) {
|
||||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true})
|
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||||
|
intermediates: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`
|
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`;
|
||||||
|
|
||||||
if (!api) throw new Error("API is not defined");
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
@@ -170,13 +185,13 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||||
setProcesses((prev) => [...prev, job]);
|
setProcesses((prev: any) => [...prev, job]);
|
||||||
|
|
||||||
await FFmpegKit.executeAsync(
|
await FFmpegKit.executeAsync(
|
||||||
createFFmpegCommand(url, output).join(" "),
|
createFFmpegCommand(url, output).join(" "),
|
||||||
(session) => completeCallback(session, item),
|
(session: any) => completeCallback(session, item),
|
||||||
undefined,
|
undefined,
|
||||||
(s) => statisticsCallback(s, item)
|
(s: any) => statisticsCallback(s, item)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as Error;
|
const error = e as Error;
|
||||||
@@ -185,8 +200,8 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||||
Error: ${error.message}, Stack: ${error.stack}`
|
Error: ${error.message}, Stack: ${error.stack}`
|
||||||
);
|
);
|
||||||
setProcesses((prev) => {
|
setProcesses((prev: any[]) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
||||||
});
|
});
|
||||||
throw error; // Re-throw the error to propagate it to the caller
|
throw error; // Re-throw the error to propagate it to the caller
|
||||||
}
|
}
|
||||||
|
|||||||
6
i18n.ts
6
i18n.ts
@@ -1,13 +1,17 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
import de from "./translations/de.json";
|
||||||
import en from "./translations/en.json";
|
import en from "./translations/en.json";
|
||||||
|
import es from "./translations/es.json";
|
||||||
import fr from "./translations/fr.json";
|
import fr from "./translations/fr.json";
|
||||||
import sv from "./translations/sv.json";
|
import sv from "./translations/sv.json";
|
||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
|
|
||||||
export const APP_LANGUAGES = [
|
export const APP_LANGUAGES = [
|
||||||
|
{ label: "Deutsch", value: "de" },
|
||||||
{ label: "English", value: "en" },
|
{ label: "English", value: "en" },
|
||||||
|
{ label: "Español", value: "es" },
|
||||||
{ label: "Français", value: "fr" },
|
{ label: "Français", value: "fr" },
|
||||||
{ label: "Svenska", value: "sv" },
|
{ label: "Svenska", value: "sv" },
|
||||||
];
|
];
|
||||||
@@ -15,7 +19,9 @@ export const APP_LANGUAGES = [
|
|||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
compatibilityJSON: "v4",
|
compatibilityJSON: "v4",
|
||||||
resources: {
|
resources: {
|
||||||
|
de: { translation: de },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
|
es: { translation: es },
|
||||||
fr: { translation: fr },
|
fr: { translation: fr },
|
||||||
sv: { translation: sv },
|
sv: { translation: sv },
|
||||||
},
|
},
|
||||||
|
|||||||
14
metro.config.js
Normal file
14
metro.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
if (process.env?.EXPO_TV === "1") {
|
||||||
|
const originalSourceExts = config.resolver.sourceExts;
|
||||||
|
const tvSourceExts = [
|
||||||
|
...originalSourceExts.map((e) => `tv.${e}`),
|
||||||
|
...originalSourceExts,
|
||||||
|
];
|
||||||
|
config.resolver.sourceExts = tvSourceExts;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
apply plugin: 'com.android.library'
|
plugins {
|
||||||
apply plugin: 'kotlin-android'
|
id 'com.android.library'
|
||||||
apply plugin: 'kotlin-kapt'
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
}
|
||||||
|
|
||||||
group = 'expo.modules.vlcplayer'
|
group = 'expo.modules.vlcplayer'
|
||||||
version = '0.6.0'
|
version = '0.6.0'
|
||||||
|
|
||||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||||
|
|
||||||
apply from: expoModulesCorePlugin
|
apply from: expoModulesCorePlugin
|
||||||
|
|
||||||
applyKotlinExpoModulesCorePlugin()
|
applyKotlinExpoModulesCorePlugin()
|
||||||
useCoreDependencies()
|
useCoreDependencies()
|
||||||
useExpoPublishing()
|
useExpoPublishing()
|
||||||
@@ -37,8 +42,8 @@ if (useManagedAndroidSdkVersions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
|
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ Pod::Spec.new do |s|
|
|||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
s.dependency 'MobileVLCKit', '~> 3.6.1b1'
|
s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1'
|
||||||
|
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1'
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
# Swift/Objective-C compatibility
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
#if os(tvOS)
|
||||||
|
import TVVLCKit
|
||||||
|
#else
|
||||||
import MobileVLCKit
|
import MobileVLCKit
|
||||||
|
#endif
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class VlcPlayerView: ExpoView {
|
class VlcPlayerView: ExpoView {
|
||||||
|
|||||||
179
package.json
179
package.json
@@ -6,123 +6,132 @@
|
|||||||
"submodule-reload": "git submodule update --init --remote --recursive",
|
"submodule-reload": "git submodule update --init --remote --recursive",
|
||||||
"clean": "echo y | expo prebuild --clean",
|
"clean": "echo y | expo prebuild --clean",
|
||||||
"start": "bun run submodule-reload && expo start",
|
"start": "bun run submodule-reload && expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"ios": "EXPO_TV=0 expo run:ios",
|
||||||
"android": "bun run submodule-reload && expo run:android",
|
"ios:tv": "EXPO_TV=1 expo run:ios",
|
||||||
"ios": "bun run submodule-reload && expo run:ios",
|
"android": "EXPO_TV=0 expo run:android",
|
||||||
"web": "bun run submodule-reload && expo start --web",
|
"android:tv": "EXPO_TV=1 expo run:android",
|
||||||
|
"prebuild": "EXPO_TV=0 bun run clean",
|
||||||
|
"prebuild:tv": "EXPO_TV=1 bun run clean",
|
||||||
|
"prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; bun run prebuild:tv",
|
||||||
"test": "jest --watchAll",
|
"test": "jest --watchAll",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"jest": {
|
|
||||||
"preset": "jest-expo"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "0.8.0",
|
"@bottom-tabs/react-navigation": "0.8.6",
|
||||||
"react-native-bottom-tabs": "0.8.0",
|
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@expo/config-plugins": "~9.0.15",
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.4",
|
"@expo/vector-icons": "^14.0.4",
|
||||||
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
"@futurejj/react-native-visibility-sensor": "^1.3.10",
|
||||||
"@gorhom/bottom-sheet": "^4.6.4",
|
"@gorhom/bottom-sheet": "^5.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "2.1.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
"@react-native-menu/menu": "^1.1.6",
|
"@react-native-menu/menu": "^1.2.2",
|
||||||
"@react-navigation/material-top-tabs": "^6.6.14",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||||
"@shopify/flash-list": "1.6.4",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@tanstack/react-query": "^5.59.20",
|
"@shopify/flash-list": "1.7.3",
|
||||||
"@types/lodash": "^4.17.13",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
"@types/react-native-vector-icons": "^6.4.18",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.9",
|
||||||
"expo": "~51.0.39",
|
"expo": "^52.0.31",
|
||||||
"expo-asset": "~10.0.10",
|
"expo-asset": "~11.0.3",
|
||||||
"expo-background-fetch": "~12.0.1",
|
"expo-background-fetch": "~13.0.5",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~14.0.3",
|
||||||
"expo-brightness": "~12.0.1",
|
"expo-brightness": "~13.0.3",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.13.2",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~17.0.5",
|
||||||
"expo-dev-client": "~4.0.29",
|
"expo-crypto": "~14.0.2",
|
||||||
"expo-device": "~6.0.2",
|
"expo-dev-client": "~5.0.11",
|
||||||
"expo-font": "~12.0.10",
|
"expo-device": "~7.0.2",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-font": "~13.0.3",
|
||||||
"expo-image": "~1.13.0",
|
"expo-haptics": "~14.0.1",
|
||||||
"expo-keep-awake": "~13.0.2",
|
"expo-image": "~2.0.4",
|
||||||
"expo-linear-gradient": "~13.0.2",
|
"expo-keep-awake": "~14.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-localization": "~16.0.0",
|
"expo-linking": "~7.0.5",
|
||||||
"expo-network": "~6.0.1",
|
"expo-localization": "~16.0.1",
|
||||||
"expo-notifications": "~0.28.19",
|
"expo-network": "~7.0.5",
|
||||||
"expo-router": "~3.5.24",
|
"expo-notifications": "~0.29.13",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
"expo-router": "~4.0.17",
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-screen-orientation": "~8.0.4",
|
||||||
"expo-splash-screen": "~0.27.7",
|
"expo-sensors": "~14.0.2",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-splash-screen": "~0.29.21",
|
||||||
"expo-system-ui": "^3.0.7",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-task-manager": "~11.8.2",
|
"expo-system-ui": "~4.0.8",
|
||||||
"expo-updates": "~0.25.27",
|
"expo-task-manager": "~12.0.5",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-updates": "~0.26.17",
|
||||||
|
"expo-web-browser": "~14.0.2",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
"install": "^0.13.0",
|
"i18next": "^24.2.2",
|
||||||
"i18next": "^24.2.0",
|
"jotai": "^2.11.3",
|
||||||
"jotai": "^2.10.1",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.3.1",
|
||||||
|
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "0.74.5",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-awesome-slider": "^2.5.6",
|
"react-native-bottom-tabs": "0.8.6",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-compressor": "^1.9.0",
|
"react-native-compressor": "^1.10.3",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^14.0.1",
|
"react-native-device-info": "^14.0.4",
|
||||||
"react-native-edge-to-edge": "^1.1.3",
|
"react-native-edge-to-edge": "^1.4.3",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.23.0",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
"react-native-google-cast": "^4.8.3",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^2.5.2",
|
"react-native-ios-context-menu": "^3.1.0",
|
||||||
"react-native-ios-utilities": "4.5.3",
|
"react-native-ios-utilities": "5.1.1",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "^2.12.2",
|
||||||
"react-native-pager-view": "6.3.0",
|
"react-native-pager-view": "6.7.0",
|
||||||
"react-native-progress": "^5.0.1",
|
"react-native-progress": "^5.0.1",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "4.0.0-canary.22",
|
"react-native-reanimated-carousel": "3.5.1",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "5.2.0",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "4.6.0",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.11.1",
|
||||||
"react-native-tab-view": "^3.5.2",
|
"react-native-tab-view": "^4.0.5",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
"react-native-uitextview": "^1.4.0",
|
"react-native-uitextview": "^1.4.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-video": "^6.7.0",
|
"react-native-video": "6.10.0",
|
||||||
"react-native-volume-manager": "^1.10.0",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "13.8.6",
|
"react-native-webview": "13.13.2",
|
||||||
"sonner-native": "^0.14.2",
|
"sonner-native": "^0.17.0",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^11.0.5",
|
||||||
"zeego": "^1.10.0",
|
"zeego": "^2.0.4",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.0",
|
"@react-native-community/cli": "15.1.3",
|
||||||
|
"@react-native-tvos/config-tv": "^0.1.1",
|
||||||
|
"@babel/core": "^7.26.8",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/react": "~18.2.79",
|
"@types/react": "~19.0.8",
|
||||||
"@types/react-test-renderer": "^18.0.7",
|
"@types/react-test-renderer": "^19.0.0",
|
||||||
"jest": "^29.2.1",
|
|
||||||
"jest-expo": "~51.0.4",
|
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "19.0.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.7.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
}
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/expo-screen-orientation.ts
Normal file
1
packages/expo-screen-orientation.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "expo-screen-orientation";
|
||||||
66
packages/expo-screen-orientation.tv.ts
Normal file
66
packages/expo-screen-orientation.tv.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export enum Orientation {
|
||||||
|
/**
|
||||||
|
* An unknown screen orientation. For example, the device is flat, perhaps on a table.
|
||||||
|
*/
|
||||||
|
UNKNOWN = 0,
|
||||||
|
/**
|
||||||
|
* Right-side up portrait interface orientation.
|
||||||
|
*/
|
||||||
|
PORTRAIT_UP = 1,
|
||||||
|
/**
|
||||||
|
* Upside down portrait interface orientation.
|
||||||
|
*/
|
||||||
|
PORTRAIT_DOWN = 2,
|
||||||
|
/**
|
||||||
|
* Left landscape interface orientation.
|
||||||
|
*/
|
||||||
|
LANDSCAPE_LEFT = 3,
|
||||||
|
/**
|
||||||
|
* Right landscape interface orientation.
|
||||||
|
*/
|
||||||
|
LANDSCAPE_RIGHT = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OrientationLock {
|
||||||
|
/**
|
||||||
|
* The default orientation. On iOS, this will allow all orientations except `Orientation.PORTRAIT_DOWN`.
|
||||||
|
* On Android, this lets the system decide the best orientation.
|
||||||
|
*/
|
||||||
|
DEFAULT = 0,
|
||||||
|
/**
|
||||||
|
* All four possible orientations
|
||||||
|
*/
|
||||||
|
ALL = 1,
|
||||||
|
/**
|
||||||
|
* Any portrait orientation.
|
||||||
|
*/
|
||||||
|
PORTRAIT = 2,
|
||||||
|
/**
|
||||||
|
* Right-side up portrait only.
|
||||||
|
*/
|
||||||
|
PORTRAIT_UP = 3,
|
||||||
|
/**
|
||||||
|
* Upside down portrait only.
|
||||||
|
*/
|
||||||
|
PORTRAIT_DOWN = 4,
|
||||||
|
/**
|
||||||
|
* Any landscape orientation.
|
||||||
|
*/
|
||||||
|
LANDSCAPE = 5,
|
||||||
|
/**
|
||||||
|
* Left landscape only.
|
||||||
|
*/
|
||||||
|
LANDSCAPE_LEFT = 6,
|
||||||
|
/**
|
||||||
|
* Right landscape only.
|
||||||
|
*/
|
||||||
|
LANDSCAPE_RIGHT = 7,
|
||||||
|
/**
|
||||||
|
* A platform specific orientation. This is not a valid policy that can be applied in [`lockAsync`](#screenorientationlockasyncorientationlock).
|
||||||
|
*/
|
||||||
|
OTHER = 8,
|
||||||
|
/**
|
||||||
|
* An unknown screen orientation lock. This is not a valid policy that can be applied in [`lockAsync`](#screenorientationlockasyncorientationlock).
|
||||||
|
*/
|
||||||
|
UNKNOWN = 9,
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||||
<base-config cleartextTrafficPermitted="false">
|
<!-- Allow cleartext network traffic -->
|
||||||
|
<base-config
|
||||||
|
cleartextTrafficPermitted="true"
|
||||||
|
tools:ignore="InsecureBaseConfiguration">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
|
<!-- Trust pre-installed CAs -->
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
|
<!-- Additionally trust user added CAs -->
|
||||||
|
<certificates
|
||||||
|
src="user"
|
||||||
|
tools:ignore="AcceptsUserCertificates" />
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
40
plugins/withGradleProperties.js
Normal file
40
plugins/withGradleProperties.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { withGradleProperties } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
function setGradlePropertiesValue(config, key, value) {
|
||||||
|
return withGradleProperties(config, exportedConfig => {
|
||||||
|
const props = exportedConfig.modResults;
|
||||||
|
const keyIdx = props.findIndex(item => item.type === 'property' && item.key === key);
|
||||||
|
const property = {
|
||||||
|
type: 'property',
|
||||||
|
key,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (keyIdx >= 0) {
|
||||||
|
props.splice(keyIdx, 1, property);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
props.push(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exportedConfig;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function withCustomPlugin(config) {
|
||||||
|
// Expo 52 is not setting this
|
||||||
|
// https://github.com/expo/expo/issues/32558
|
||||||
|
config = setGradlePropertiesValue(
|
||||||
|
config,
|
||||||
|
'android.enableJetifier',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Increase memory
|
||||||
|
config = setGradlePropertiesValue(
|
||||||
|
config,
|
||||||
|
'org.gradle.jvmargs',
|
||||||
|
'-Xmx4096m -XX:MaxMetaspaceSize=1024m',
|
||||||
|
);
|
||||||
|
return config;
|
||||||
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
|
import useDownloadHelper from "@/utils/download";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { useLog, writeToLog } from "@/utils/log";
|
import { useLog, writeToLog } from "@/utils/log";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import {
|
import {
|
||||||
cancelAllJobs,
|
cancelAllJobs,
|
||||||
cancelJobById,
|
cancelJobById,
|
||||||
@@ -13,22 +18,11 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
checkForExistingDownloads,
|
|
||||||
completeHandler,
|
|
||||||
download,
|
|
||||||
setConfig,
|
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import MMKV from "react-native-mmkv";
|
|
||||||
import {
|
|
||||||
focusManager,
|
|
||||||
QueryClient,
|
|
||||||
QueryClientProvider,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import * as Application from "expo-application";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { FileInfo } from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -37,20 +31,16 @@ import React, {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { AppState, AppStateStatus, Platform } from "react-native";
|
import { AppState, AppStateStatus, Platform } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { apiAtom } from "./JellyfinProvider";
|
import { apiAtom } from "./JellyfinProvider";
|
||||||
import * as Notifications from "expo-notifications";
|
const BackGroundDownloader = !Platform.isTV
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader"))
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
: null;
|
||||||
import { storage } from "@/utils/mmkv";
|
// import * as Notifications from "expo-notifications";
|
||||||
import useDownloadHelper from "@/utils/download";
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import { FileInfo } from "expo-file-system";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import * as Application from "expo-application";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
@@ -68,6 +58,8 @@ const DownloadContext = createContext<ReturnType<
|
|||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
|
||||||
function useDownloadProvider() {
|
function useDownloadProvider() {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
@@ -141,15 +133,20 @@ function useDownloadProvider() {
|
|||||||
if (settings.autoDownload) {
|
if (settings.autoDownload) {
|
||||||
startDownload(job);
|
startDownload(job);
|
||||||
} else {
|
} else {
|
||||||
toast.info(t("home.downloads.toasts.item_is_ready_to_be_downloaded",{item: job.item.Name}), {
|
toast.info(
|
||||||
action: {
|
t("home.downloads.toasts.item_is_ready_to_be_downloaded", {
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
item: job.item.Name,
|
||||||
onClick: () => {
|
}),
|
||||||
router.push("/downloads");
|
{
|
||||||
toast.dismiss();
|
action: {
|
||||||
|
label: t("home.downloads.toasts.go_to_downloads"),
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
);
|
||||||
Notifications.scheduleNotificationAsync({
|
Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: job.item.Name,
|
title: job.item.Name,
|
||||||
@@ -174,7 +171,7 @@ function useDownloadProvider() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkIfShouldStartDownload = async () => {
|
const checkIfShouldStartDownload = async () => {
|
||||||
if (processes.length === 0) return;
|
if (processes.length === 0) return;
|
||||||
await checkForExistingDownloads();
|
await BackGroundDownloader?.checkForExistingDownloads();
|
||||||
};
|
};
|
||||||
|
|
||||||
checkIfShouldStartDownload();
|
checkIfShouldStartDownload();
|
||||||
@@ -218,7 +215,7 @@ function useDownloadProvider() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
setConfig({
|
BackGroundDownloader?.setConfig({
|
||||||
isLogsEnabled: true,
|
isLogsEnabled: true,
|
||||||
progressInterval: 500,
|
progressInterval: 500,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -226,19 +223,24 @@ function useDownloadProvider() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.info(t("home.downloads.toasts.download_stated_for_item", {item: process.item.Name}), {
|
toast.info(
|
||||||
action: {
|
t("home.downloads.toasts.download_stated_for_item", {
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
item: process.item.Name,
|
||||||
onClick: () => {
|
}),
|
||||||
router.push("/downloads");
|
{
|
||||||
toast.dismiss();
|
action: {
|
||||||
|
label: t("home.downloads.toasts.go_to_downloads"),
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
download({
|
BackGroundDownloader?.download({
|
||||||
id: process.id,
|
id: process.id,
|
||||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||||
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
||||||
@@ -277,24 +279,29 @@ function useDownloadProvider() {
|
|||||||
process.item,
|
process.item,
|
||||||
doneHandler.bytesDownloaded
|
doneHandler.bytesDownloaded
|
||||||
);
|
);
|
||||||
toast.success(t("home.downloads.toasts.download_completed_for_item", {item: process.item.Name}), {
|
toast.success(
|
||||||
duration: 3000,
|
t("home.downloads.toasts.download_completed_for_item", {
|
||||||
action: {
|
item: process.item.Name,
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
}),
|
||||||
onClick: () => {
|
{
|
||||||
router.push("/downloads");
|
duration: 3000,
|
||||||
toast.dismiss();
|
action: {
|
||||||
|
label: t("home.downloads.toasts.go_to_downloads"),
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
completeHandler(process.id);
|
BackGroundDownloader.completeHandler(process.id);
|
||||||
removeProcess(process.id);
|
removeProcess(process.id);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
})
|
})
|
||||||
.error(async (error) => {
|
.error(async (error) => {
|
||||||
removeProcess(process.id);
|
removeProcess(process.id);
|
||||||
completeHandler(process.id);
|
BackGroundDownloader.completeHandler(process.id);
|
||||||
let errorMsg = "";
|
let errorMsg = "";
|
||||||
if (error.errorCode === 1000) {
|
if (error.errorCode === 1000) {
|
||||||
errorMsg = "No space left";
|
errorMsg = "No space left";
|
||||||
@@ -302,7 +309,12 @@ function useDownloadProvider() {
|
|||||||
if (error.errorCode === 404) {
|
if (error.errorCode === 404) {
|
||||||
errorMsg = "File not found on server";
|
errorMsg = "File not found on server";
|
||||||
}
|
}
|
||||||
toast.error(t("home.downloads.toasts.download_failed_for_item", {item: process.item.Name, error: errorMsg}));
|
toast.error(
|
||||||
|
t("home.downloads.toasts.download_failed_for_item", {
|
||||||
|
item: process.item.Name,
|
||||||
|
error: errorMsg,
|
||||||
|
})
|
||||||
|
);
|
||||||
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
||||||
error,
|
error,
|
||||||
processDetails: {
|
processDetails: {
|
||||||
@@ -359,15 +371,20 @@ function useDownloadProvider() {
|
|||||||
throw new Error("Failed to start optimization job");
|
throw new Error("Failed to start optimization job");
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(t("home.downloads.toasts.queued_item_for_optimization", {item: item.Name}), {
|
toast.success(
|
||||||
action: {
|
t("home.downloads.toasts.queued_item_for_optimization", {
|
||||||
label: t("home.downloads.toasts.go_to_downloads"),
|
item: item.Name,
|
||||||
onClick: () => {
|
}),
|
||||||
router.push("/downloads");
|
{
|
||||||
toast.dismiss();
|
action: {
|
||||||
|
label: t("home.downloads.toasts.go_to_downloads"),
|
||||||
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error in startBackgroundDownload", error);
|
writeToLog("ERROR", "Error in startBackgroundDownload", error);
|
||||||
console.error("Error in startBackgroundDownload:", error);
|
console.error("Error in startBackgroundDownload:", error);
|
||||||
@@ -379,11 +396,16 @@ function useDownloadProvider() {
|
|||||||
headers: error.response?.headers,
|
headers: error.response?.headers,
|
||||||
});
|
});
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.failed_to_start_download_for_item", {item: item.Name, message: error.message})
|
t("home.downloads.toasts.failed_to_start_download_for_item", {
|
||||||
|
item: item.Name,
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.server_responded_with_status", {statusCode: error.response.status})
|
t("home.downloads.toasts.server_responded_with_status", {
|
||||||
|
statusCode: error.response.status,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
t("home.downloads.toasts.no_response_received_from_server");
|
t("home.downloads.toasts.no_response_received_from_server");
|
||||||
@@ -393,7 +415,10 @@ function useDownloadProvider() {
|
|||||||
} else {
|
} else {
|
||||||
console.error("Non-Axios error:", error);
|
console.error("Non-Axios error:", error);
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", {item: item.Name})
|
t(
|
||||||
|
"home.downloads.toasts.failed_to_start_download_for_item_unexpected_error",
|
||||||
|
{ item: item.Name }
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,11 +434,19 @@ function useDownloadProvider() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
||||||
])
|
])
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(t("home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"))
|
toast.success(
|
||||||
|
t(
|
||||||
|
"home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
console.error("Failed to delete all files, folders, and jobs:", reason);
|
console.error("Failed to delete all files, folders, and jobs:", reason);
|
||||||
toast.error(t("home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"));
|
toast.error(
|
||||||
|
t(
|
||||||
|
"home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import { getDeviceName } from "react-native-device-info";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import {
|
||||||
|
useSplashScreenLoading,
|
||||||
|
useSplashScreenVisible,
|
||||||
|
} from "./SplashScreenProvider";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -266,7 +270,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
case 401:
|
case 401:
|
||||||
throw new Error(t("login.invalid_username_or_password"));
|
throw new Error(t("login.invalid_username_or_password"));
|
||||||
case 403:
|
case 403:
|
||||||
throw new Error(t("login.user_does_not_have_permission_to_log_in"));
|
throw new Error(
|
||||||
|
t("login.user_does_not_have_permission_to_log_in")
|
||||||
|
);
|
||||||
case 408:
|
case 408:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t("login.server_is_taking_too_long_to_respond_try_again_later")
|
t("login.server_is_taking_too_long_to_respond_try_again_later")
|
||||||
@@ -279,7 +285,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
throw new Error(t("login.there_is_a_server_error"));
|
throw new Error(t("login.there_is_a_server_error"));
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t("login.an_unexpected_error_occured_did_you_enter_the_correct_url")
|
t(
|
||||||
|
"login.an_unexpected_error_occured_did_you_enter_the_correct_url"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,11 +349,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
};
|
};
|
||||||
|
|
||||||
useProtectedRoute(user, isLoading || isFetching);
|
let isLoadingOrFetching = isLoading || isFetching;
|
||||||
|
useProtectedRoute(user, isLoadingOrFetching);
|
||||||
|
|
||||||
|
// show splash screen until everything loaded
|
||||||
|
useSplashScreenLoading(isLoadingOrFetching);
|
||||||
|
const splashScreenVisible = useSplashScreenVisible();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JellyfinContext.Provider value={contextValue}>
|
<JellyfinContext.Provider value={contextValue}>
|
||||||
{children}
|
{/* don't render login page when loading and splash screen visible */}
|
||||||
|
{isLoadingOrFetching && splashScreenVisible ? undefined : children}
|
||||||
</JellyfinContext.Provider>
|
</JellyfinContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
103
providers/SplashScreenProvider.tsx
Normal file
103
providers/SplashScreenProvider.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
|
||||||
|
type SplashScreenContextValue = {
|
||||||
|
registerLoadingComponent: () => () => void;
|
||||||
|
splashScreenVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SplashScreenContext = createContext<SplashScreenContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prevent splash screen from auto-hiding
|
||||||
|
void SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export const SplashScreenProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [splashScreenVisible, setSplashScreenVisible] = useState(true);
|
||||||
|
const loadingComponentsCount = useRef(0);
|
||||||
|
const isHidingRef = useRef(false);
|
||||||
|
|
||||||
|
const hideScreenIfNoLoadingComponents = async () => {
|
||||||
|
if (loadingComponentsCount.current === 0 && !isHidingRef.current) {
|
||||||
|
try {
|
||||||
|
isHidingRef.current = true;
|
||||||
|
await SplashScreen.hideAsync();
|
||||||
|
setSplashScreenVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to hide splash screen:", error);
|
||||||
|
} finally {
|
||||||
|
isHidingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerLoadingComponent = () => {
|
||||||
|
loadingComponentsCount.current += 1;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
loadingComponentsCount.current -= 1;
|
||||||
|
void hideScreenIfNoLoadingComponents();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextValue: SplashScreenContextValue = {
|
||||||
|
registerLoadingComponent,
|
||||||
|
splashScreenVisible,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplashScreenContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SplashScreenContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the Splash Screen until component is ready to be displayed.
|
||||||
|
*
|
||||||
|
* @param isLoading The loading state of the component
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
* ```
|
||||||
|
* const isLoading = loadSomething()
|
||||||
|
* useSplashScreenLoading(isLoading) // splash screen visible until isLoading is false
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSplashScreenLoading(isLoading: boolean) {
|
||||||
|
const context = useContext(SplashScreenContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useSplashScreenLoading must be used within a SplashScreenProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
return context.registerLoadingComponent();
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the visibility of the Splash Screen.
|
||||||
|
* @returns the visibility of the Splash Screen
|
||||||
|
*/
|
||||||
|
export function useSplashScreenVisible() {
|
||||||
|
const context = useContext(SplashScreenContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useSplashScreenVisible must be used within a SplashScreenProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context.splashScreenVisible;
|
||||||
|
}
|
||||||
62
scripts/symlink-native-dirs.js
Normal file
62
scripts/symlink-native-dirs.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const process = require("process");
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
// const tvosPath = path.join(root, 'iostv');
|
||||||
|
// const iosPath = path.join(root, 'iosmobile');
|
||||||
|
// const androidPath = path.join(root, 'androidmobile');
|
||||||
|
// const androidTVPath = path.join(root, 'androidtv');
|
||||||
|
// const device = process.argv[2];
|
||||||
|
// const platform = process.argv[2];
|
||||||
|
const isTV = process.env.EXPO_TV || false;
|
||||||
|
|
||||||
|
const paths = new Map([
|
||||||
|
["tvos", path.join(root, "iostv")],
|
||||||
|
["ios", path.join(root, "iosmobile")],
|
||||||
|
["android", path.join(root, "androidmobile")],
|
||||||
|
["androidtv", path.join(root, "androidtv")],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// const platformPath = paths.get(platform);
|
||||||
|
|
||||||
|
if (isTV) {
|
||||||
|
stdout = execSync(
|
||||||
|
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`
|
||||||
|
);
|
||||||
|
console.log(stdout.toString());
|
||||||
|
stdout = execSync(
|
||||||
|
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
||||||
|
"androidtv"
|
||||||
|
)} android`
|
||||||
|
);
|
||||||
|
console.log(stdout.toString());
|
||||||
|
} else {
|
||||||
|
stdout = execSync(
|
||||||
|
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`
|
||||||
|
);
|
||||||
|
console.log(stdout.toString());
|
||||||
|
stdout = execSync(
|
||||||
|
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`
|
||||||
|
);
|
||||||
|
console.log(stdout.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// target = "";
|
||||||
|
// switch (platform) {
|
||||||
|
// case "tvos":
|
||||||
|
// target = "ios";
|
||||||
|
// break;
|
||||||
|
// case "ios":
|
||||||
|
// target = "ios";
|
||||||
|
// break;
|
||||||
|
// case "android":
|
||||||
|
// target = "android";
|
||||||
|
// break;
|
||||||
|
// case "androidtv":
|
||||||
|
// target = "android";
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
457
translations/de.json
Normal file
457
translations/de.json
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"username_required": "Benutzername ist erforderlich",
|
||||||
|
"error_title": "Fehler",
|
||||||
|
"login_title": "Anmelden",
|
||||||
|
"login_to_title": "Anmelden bei",
|
||||||
|
"username_placeholder": "Benutzername",
|
||||||
|
"password_placeholder": "Passwort",
|
||||||
|
"login_button": "Anmelden",
|
||||||
|
"quick_connect": "Schnellverbindung",
|
||||||
|
"enter_code_to_login": "Gib den Code {{code}} ein, um dich anzumelden",
|
||||||
|
"failed_to_initiate_quick_connect": "Fehler beim Initiieren der Schnellverbindung",
|
||||||
|
"got_it": "Verstanden",
|
||||||
|
"connection_failed": "Verbindung fehlgeschlagen",
|
||||||
|
"could_not_connect_to_server": "Verbindung zum Server fehlgeschlagen. Bitte überprüf die URL und deine Netzwerkverbindung.",
|
||||||
|
"an_unexpected_error_occured": "Ein unerwarteter Fehler ist aufgetreten",
|
||||||
|
"change_server": "Server wechseln",
|
||||||
|
"invalid_username_or_password": "Ungültiger Benutzername oder Passwort",
|
||||||
|
"user_does_not_have_permission_to_log_in": "Benutzer hat keine Berechtigung, um sich anzumelden",
|
||||||
|
"server_is_taking_too_long_to_respond_try_again_later": "Der Server benötigt zu lange, um zu antworten. Bitte versuch es später erneut.",
|
||||||
|
"server_received_too_many_requests_try_again_later": "Der Server hat zu viele Anfragen erhalten. Bitte versuch es später erneut.",
|
||||||
|
"there_is_a_server_error": "Es gibt einen Serverfehler",
|
||||||
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"enter_url_to_jellyfin_server": "Gib die URL zu deinem Jellyfin-Server ein",
|
||||||
|
"server_url_placeholder": "http(s)://dein-server.de",
|
||||||
|
"connect_button": "Verbinden",
|
||||||
|
"previous_servers": "Vorherige Server",
|
||||||
|
"clear_button": "Löschen",
|
||||||
|
"search_for_local_servers": "Nach lokalen Servern suchen",
|
||||||
|
"searching": "Suche...",
|
||||||
|
"servers": "Server"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"no_internet": "Kein Internet",
|
||||||
|
"no_items": "Keine Elemente",
|
||||||
|
"no_internet_message": "Keine Sorge, du kannst immer noch heruntergeladene Inhalte ansehen.",
|
||||||
|
"go_to_downloads": "Gehe zu den Downloads",
|
||||||
|
"oops": "Ups!",
|
||||||
|
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||||
|
"continue_watching": "Weiterschauen",
|
||||||
|
"next_up": "Als nächstes",
|
||||||
|
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||||
|
"suggested_movies": "Empfohlene Filme",
|
||||||
|
"suggested_episodes": "Empfohlene Episoden",
|
||||||
|
"intro": {
|
||||||
|
"welcome_to_streamyfin": "Willkommen bei Streamyfin",
|
||||||
|
"a_free_and_open_source_client_for_jellyfin": "Ein kostenloser und Open-Source-Client für Jellyfin.",
|
||||||
|
"features_title": "Features",
|
||||||
|
"features_description": "Streamyfin hat viele Features und integriert sich mit einer Vielzahl von Software, die du im Einstellungsmenü findest. Dazu gehören:",
|
||||||
|
"jellyseerr_feature_description": "Verbinde dich mit deiner Jellyseerr-Instanz und frage Filme direkt in der App an.",
|
||||||
|
"downloads_feature_title": "Downloads",
|
||||||
|
"downloads_feature_description": "Lade Filme und Serien herunter, um sie offline anzusehen. Nutze entweder die Standardmethode oder installiere den optimierten Server, um Dateien im Hintergrund herunterzuladen.",
|
||||||
|
"chromecast_feature_description": "Übertrage Filme und Serien auf deine Chromecast-Geräte.",
|
||||||
|
"centralised_settings_plugin_title": "Zentralisiertes Einstellungs-Plugin",
|
||||||
|
"centralised_settings_plugin_description": "Konfiguriere Einstellungen an einem zentralen Ort auf deinem Jellyfin-Server. Alle Client-Einstellungen für alle Benutzer werden automatisch synchronisiert.",
|
||||||
|
"done_button": "Fertig",
|
||||||
|
"go_to_settings_button": "Gehe zu den Einstellungen",
|
||||||
|
"read_more": "Mehr Erfahren"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings_title": "Einstellungen",
|
||||||
|
"log_out_button": "Abmelden",
|
||||||
|
"user_info": {
|
||||||
|
"user_info_title": "Benutzerinformationen",
|
||||||
|
"user": "Benutzer",
|
||||||
|
"server": "Server",
|
||||||
|
"token": "Token",
|
||||||
|
"app_version": "App-Version"
|
||||||
|
},
|
||||||
|
"quick_connect": {
|
||||||
|
"quick_connect_title": "Schnellverbindung",
|
||||||
|
"authorize_button": "Schnellverbindung autorisieren",
|
||||||
|
"enter_the_quick_connect_code": "Gib den Schnellverbindungscode ein...",
|
||||||
|
"success": "Erfolg",
|
||||||
|
"quick_connect_autorized": "Schnellverbindung autorisiert",
|
||||||
|
"error": "Fehler",
|
||||||
|
"invalid_code": "Ungültiger Code",
|
||||||
|
"authorize": "Autorisieren"
|
||||||
|
},
|
||||||
|
"media_controls": {
|
||||||
|
"media_controls_title": "Mediensteuerung",
|
||||||
|
"forward_skip_length": "Vorspulzeit",
|
||||||
|
"rewind_length": "Rückspulzeit",
|
||||||
|
"seconds_unit": "s"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"audio_title": "Audio",
|
||||||
|
"set_audio_track": "Audiospur aus dem vorherigen Element festlegen",
|
||||||
|
"audio_language": "Audio-Sprache",
|
||||||
|
"audio_hint": "Wähl die Standardsprache für Audio aus.",
|
||||||
|
"none": "Keine",
|
||||||
|
"language": "Sprache"
|
||||||
|
},
|
||||||
|
"subtitles": {
|
||||||
|
"subtitle_title": "Untertitel",
|
||||||
|
"subtitle_language": "Untertitel-Sprache",
|
||||||
|
"subtitle_mode": "Untertitel-Modus",
|
||||||
|
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element festlegen",
|
||||||
|
"subtitle_size": "Untertitel-Größe",
|
||||||
|
"subtitle_hint": "Konfigurier die Untertitel-Präferenzen.",
|
||||||
|
"none": "Keine",
|
||||||
|
"language": "Sprache",
|
||||||
|
"loading": "Lädt",
|
||||||
|
"modes": {
|
||||||
|
"Default": "Standard",
|
||||||
|
"Smart": "Smart",
|
||||||
|
"Always": "Immer",
|
||||||
|
"None": "Keine",
|
||||||
|
"OnlyForced": "Nur erzwungen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"other_title": "Sonstiges",
|
||||||
|
"auto_rotate": "Automatische Drehung",
|
||||||
|
"video_orientation": "Videoausrichtung",
|
||||||
|
"orientation": "Ausrichtung",
|
||||||
|
"orientations": {
|
||||||
|
"DEFAULT": "Standard",
|
||||||
|
"ALL": "Alle",
|
||||||
|
"PORTRAIT": "Hochformat",
|
||||||
|
"PORTRAIT_UP": "Hochformat oben",
|
||||||
|
"PORTRAIT_DOWN": "Hochformat unten",
|
||||||
|
"LANDSCAPE": "Querformat",
|
||||||
|
"LANDSCAPE_LEFT": "Querformat links",
|
||||||
|
"LANDSCAPE_RIGHT": "Querformat rechts",
|
||||||
|
"OTHER": "Andere",
|
||||||
|
"UNKNOWN": "Unbekannt"
|
||||||
|
},
|
||||||
|
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||||
|
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||||
|
"hide_libraries": "Bibliotheken ausblenden",
|
||||||
|
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
|
||||||
|
"disable_haptic_feedback": "Haptisches Feedback deaktivieren"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Downloads",
|
||||||
|
"download_method": "Download-Methode",
|
||||||
|
"remux_max_download": "Maximaler Remux-Download",
|
||||||
|
"auto_download": "Automatischer Download",
|
||||||
|
"optimized_versions_server": "Optimierter Versions-Server",
|
||||||
|
"save_button": "Speichern",
|
||||||
|
"optimized_server": "Optimierter Server",
|
||||||
|
"optimized": "Optimiert",
|
||||||
|
"default": "Standard",
|
||||||
|
"optimized_version_hint": "Gib die URL für den optimierten Server ein. Die URL sollte http oder https enthalten und optional den Port.",
|
||||||
|
"read_more_about_optimized_server": "Mehr über den optimierten Server lesen.",
|
||||||
|
"url":"URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"plugins_title": "Plugins",
|
||||||
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "Diese integration ist in einer frühen Entwicklungsphase. Erwarte Veränderungen.",
|
||||||
|
"server_url": "Server URL",
|
||||||
|
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Portnummer hinzufügen, falls erforderlich)",
|
||||||
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
|
"password": "Passwort",
|
||||||
|
"password_placeholder": "Passwort für Jellyfin Benutzer {{username}} eingeben",
|
||||||
|
"save_button": "Speichern",
|
||||||
|
"clear_button": "Löschen",
|
||||||
|
"login_button": "Anmelden",
|
||||||
|
"total_media_requests": "Gesamtanfragen",
|
||||||
|
"movie_quota_limit": "Film-Anfragelimit",
|
||||||
|
"movie_quota_days": "Film-Anfragetage",
|
||||||
|
"tv_quota_limit": "TV-Anfragelimit",
|
||||||
|
"tv_quota_days": "TV-Anfragetage",
|
||||||
|
"reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück",
|
||||||
|
"unlimited": "Unlimitiert"
|
||||||
|
},
|
||||||
|
"marlin_search": {
|
||||||
|
"enable_marlin_search": "Aktiviere Marlin Search",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port",
|
||||||
|
"marlin_search_hint": "Gib die URL für den Marlin Server ein. Die URL sollte http oder https enthalten und optional den Port.",
|
||||||
|
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||||
|
"save_button": "Speichern",
|
||||||
|
"toasts": {
|
||||||
|
"saved": "Gespeichert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"storage_title": "Speicher",
|
||||||
|
"app_usage": "App {{usedSpace}}%",
|
||||||
|
"device_usage": "Gerät {{availableSpace}}%",
|
||||||
|
"size_used": "{{used}} von {{total}} benutzt",
|
||||||
|
"delete_all_downloaded_files": "Alle Downloads löschen"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"show_intro": "Show intro",
|
||||||
|
"reset_intro": "Reset intro"
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"logs_title": "Logs",
|
||||||
|
"no_logs_available": "Keine Logs verfügbar",
|
||||||
|
"delete_all_logs": "Alle Logs löschen"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"title": "Sprachen",
|
||||||
|
"app_language": "App-Sprache",
|
||||||
|
"app_language_description": "Wähle die Sprache für die App aus.",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"toasts":{
|
||||||
|
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
||||||
|
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
||||||
|
"background_downloads_disabled": "Hintergrunddownloads deaktiviert",
|
||||||
|
"connected": "Verbunden",
|
||||||
|
"could_not_connect": "Konnte keine Verbindung herstellen",
|
||||||
|
"invalid_url": "Ungültige URL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Downloads",
|
||||||
|
"tvseries": "TV-Serien",
|
||||||
|
"movies": "Filme",
|
||||||
|
"queue": "Warteschlange",
|
||||||
|
"queue_hint": "Warteschlange und aktive Downloads gehen verloren bei App-Neustart",
|
||||||
|
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||||
|
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||||
|
"delete_all_movies_button": "Alle Filme löschen",
|
||||||
|
"delete_all_tvseries_button": "Alle TV-Serien löschen",
|
||||||
|
"delete_all_button": "Alles löschen",
|
||||||
|
"active_download": "Aktiver Download",
|
||||||
|
"no_active_downloads": "Keine aktiven Downloads",
|
||||||
|
"active_downloads": "Aktive Downloads",
|
||||||
|
"new_app_version_requires_re_download": "Die neue App-Version erfordert das erneute Herunterladen.",
|
||||||
|
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
||||||
|
"back": "Zurück",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||||
|
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
||||||
|
"eta": "ETA {{eta}}",
|
||||||
|
"methods": "Methoden",
|
||||||
|
"toasts": {
|
||||||
|
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
||||||
|
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
||||||
|
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
||||||
|
"deleted_all_tvseries_successfully": "Alle TV-Serien erfolgreich gelöscht!",
|
||||||
|
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller TV-Serien",
|
||||||
|
"download_cancelled": "Download abgebrochen",
|
||||||
|
"could_not_cancel_download": "Download konnte nicht abgebrochen werden",
|
||||||
|
"download_completed": "Download abgeschlossen",
|
||||||
|
"download_started_for": "Download für {{item}} gestartet",
|
||||||
|
"item_is_ready_to_be_downloaded": "{{item}} ist bereit zum Herunterladen",
|
||||||
|
"download_stated_for_item": "Download für {{item}} gestartet",
|
||||||
|
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
||||||
|
"download_completed_for_item": "Download für {{item}} ",
|
||||||
|
"queued_item_for_optimization": "{{item}} für Optimierung in die Warteschlange gestellt",
|
||||||
|
"failed_to_start_download_for_item": "Download konnte für {{item}} nicht gestartet werden: {{message}}",
|
||||||
|
"server_responded_with_status_code": "Server hat mit Status {{statusCode}} geantwortet",
|
||||||
|
"no_response_received_from_server": "Keine Antwort vom Server erhalten",
|
||||||
|
"error_setting_up_the_request": "Fehler beim Einrichten der Anfrage",
|
||||||
|
"failed_to_start_download_for_item_unexpected_error": "Fehler beim Starten des Downloads für {{item}}: Unerwarteter Fehler",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
|
||||||
|
"an_error_occured_while_deleting_files_and_jobs": "Ein Fehler ist beim Löschen von Dateien und Jobs aufgetreten",
|
||||||
|
"go_to_downloads": "Gehe zu den Downloads"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"search_here": "Hier Suchen...",
|
||||||
|
"search": "Suche...",
|
||||||
|
"x_items": "{{count}} Elemente",
|
||||||
|
"library": "Bibliothek",
|
||||||
|
"discover": "Entdecken",
|
||||||
|
"no_results": "Keine Ergebnisse",
|
||||||
|
"no_results_found_for": "Keine Ergebnisse gefunden für",
|
||||||
|
"movies": "Filme",
|
||||||
|
"series": "Serien",
|
||||||
|
"episodes": "Episoden",
|
||||||
|
"collections": "Sammlungen",
|
||||||
|
"actors": "Schauspieler",
|
||||||
|
"request_movies": "Film anfragen",
|
||||||
|
"request_series": "Serie anfragen",
|
||||||
|
"recently_added": "Kürzlich hinzugefügt",
|
||||||
|
"recent_requests": "Kürzlich angefragt",
|
||||||
|
"plex_watchlist": "Plex Watchlist",
|
||||||
|
"trending": "In den Trends",
|
||||||
|
"popular_movies": "Beliebte Filme",
|
||||||
|
"movie_genres": "Film-Genres",
|
||||||
|
"upcoming_movies": "Kommende Filme",
|
||||||
|
"studios": "Studios",
|
||||||
|
"popular_tv": "Beliebte TV-Serien",
|
||||||
|
"tv_genres": "TV-Serien-Genres",
|
||||||
|
"upcoming_tv": "Kommende TV-Serien",
|
||||||
|
"networks": "Netzwerke",
|
||||||
|
"tmdb_movie_keyword": "TMDB Film-Schlüsselwort",
|
||||||
|
"tmdb_movie_genre": "TMDB Film-Genre",
|
||||||
|
"tmdb_tv_keyword": "TMDB TV-Serien-Schlüsselwort",
|
||||||
|
"tmdb_tv_genre": "TMDB TV-Serien-Genre",
|
||||||
|
"tmdb_search": "TMDB Suche",
|
||||||
|
"tmdb_studio": "TMDB Studio",
|
||||||
|
"tmdb_network": "TMDB Netzwerk",
|
||||||
|
"tmdb_movie_streaming_services": "TMDB Film-Streaming-Dienste",
|
||||||
|
"tmdb_tv_streaming_services": "TMDB TV-Serien-Streaming-Dienste"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"no_items_found": "Keine Elemente gefunden",
|
||||||
|
"no_results": "Keine Ergebnisse",
|
||||||
|
"no_libraries_found": "Keine Bibliotheken gefunden",
|
||||||
|
"item_types": {
|
||||||
|
"movies": "Filme",
|
||||||
|
"series": "Serien",
|
||||||
|
"boxsets": "Boxsets",
|
||||||
|
"items": "Elemente"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"display": "Display",
|
||||||
|
"row": "Reihe",
|
||||||
|
"list": "Liste",
|
||||||
|
"image_style": "Bildstil",
|
||||||
|
"poster": "Poster",
|
||||||
|
"cover": "Cover",
|
||||||
|
"show_titles": "Titel anzeigen",
|
||||||
|
"show_stats": "Statistiken anzeigen"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"genres": "Genres",
|
||||||
|
"years": "Jahre",
|
||||||
|
"sort_by": "Sortieren nach",
|
||||||
|
"sort_order": "Sortierreihenfolge",
|
||||||
|
"tags": "Tags"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"series": "Serien",
|
||||||
|
"movies": "Filme",
|
||||||
|
"episodes": "Episoden",
|
||||||
|
"videos": "Videos",
|
||||||
|
"boxsets": "Boxsets",
|
||||||
|
"playlists": "Playlists"
|
||||||
|
},
|
||||||
|
"custom_links": {
|
||||||
|
"no_links": "Keine Links"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Fehler",
|
||||||
|
"failed_to_get_stream_url": "Fehler beim Abrufen der Stream-URL",
|
||||||
|
"an_error_occured_while_playing_the_video": "Ein Fehler ist beim Abspielen des Videos aufgetreten. Überprüf die Logs in den Einstellungen.",
|
||||||
|
"client_error": "Client-Fehler",
|
||||||
|
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||||
|
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||||
|
"video_has_finished_playing": "Video wurde fertig abgespielt!",
|
||||||
|
"no_video_source": "Keine Videoquelle...",
|
||||||
|
"next_episode": "Nächste Episode",
|
||||||
|
"refresh_tracks": "Spuren aktualisieren",
|
||||||
|
"subtitle_tracks": "Untertitel-Spuren:",
|
||||||
|
"audio_tracks": "Audiospuren:",
|
||||||
|
"playback_state": "Wiedergabestatus:",
|
||||||
|
"no_data_available": "Keine Daten verfügbar",
|
||||||
|
"index": "Index:"
|
||||||
|
},
|
||||||
|
"item_card": {
|
||||||
|
"next_up": "Als nächstes",
|
||||||
|
"no_items_to_display": "Keine Elemente zum Anzeigen",
|
||||||
|
"cast_and_crew": "Besetzung und Crew",
|
||||||
|
"series": "Serien",
|
||||||
|
"seasons": "Staffeln",
|
||||||
|
"season": "Staffel",
|
||||||
|
"no_episodes_for_this_season": "Keine Episoden für diese Staffel",
|
||||||
|
"overview": "Überblick",
|
||||||
|
"more_with": "Mehr mit {{name}}",
|
||||||
|
"similar_items": "Ähnliche Elemente",
|
||||||
|
"no_similar_items_found": "Keine ähnlichen Elemente gefunden",
|
||||||
|
"video": "Video",
|
||||||
|
"more_details": "Mehr Details",
|
||||||
|
"quality": "Qualität",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Untertitel",
|
||||||
|
"show_more": "Mehr anzeigen",
|
||||||
|
"show_less": "Weniger anzeigen",
|
||||||
|
"appeared_in": "Erschienen in",
|
||||||
|
"could_not_load_item": "Konnte Element nicht laden",
|
||||||
|
"none": "Keine",
|
||||||
|
"download": {
|
||||||
|
"download_season": "Staffel herunterladen",
|
||||||
|
"download_series": "Serie herunterladen",
|
||||||
|
"download_episode": "Episode herunterladen",
|
||||||
|
"download_movie": "Film herunterladen",
|
||||||
|
"download_x_item": "{{item_count}} Elemente herunterladen",
|
||||||
|
"download_button": "Herunterladen",
|
||||||
|
"using_optimized_server": "Verwende optimierten Server",
|
||||||
|
"using_default_method": "Verwende Standardmethode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"live_tv": {
|
||||||
|
"next": "Nächster",
|
||||||
|
"previous": "Vorheriger",
|
||||||
|
"live_tv": "Live TV",
|
||||||
|
"coming_soon": "Demnächst",
|
||||||
|
"on_now": "Jetzt",
|
||||||
|
"shows": "Shows",
|
||||||
|
"movies": "Filme",
|
||||||
|
"sports": "Sport",
|
||||||
|
"for_kids": "Für Kinder",
|
||||||
|
"news": "Nachrichten"
|
||||||
|
},
|
||||||
|
"jellyseerr":{
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"yes": "Ja",
|
||||||
|
"whats_wrong": "Hast du Probleme?",
|
||||||
|
"issue_type": "Fehlerart",
|
||||||
|
"select_an_issue": "Wähle einen Fehlerart aus",
|
||||||
|
"types": "Arten",
|
||||||
|
"describe_the_issue": "(optional) Beschreibe das Problem",
|
||||||
|
"submit_button": "Absenden",
|
||||||
|
"report_issue_button": "Fehler melden",
|
||||||
|
"request_button": "Anfragen",
|
||||||
|
"are_you_sure_you_want_to_request_all_seasons": "Bist du sicher, dass du alle Staffeln anfragen möchtest?",
|
||||||
|
"failed_to_login": "Fehler beim Anmelden",
|
||||||
|
"cast": "Besetzung",
|
||||||
|
"details": "Details",
|
||||||
|
"status": "Status",
|
||||||
|
"original_title": "Original Titel",
|
||||||
|
"series_type": "Serien Typ",
|
||||||
|
"release_dates": "Veröffentlichungsdaten",
|
||||||
|
"first_air_date": "Erstausstrahlungsdatum",
|
||||||
|
"next_air_date": "Nächstes Ausstrahlungsdatum",
|
||||||
|
"revenue": "Einnahmen",
|
||||||
|
"budget": "Budget",
|
||||||
|
"original_language": "Originalsprache",
|
||||||
|
"production_country": "Produktionsland",
|
||||||
|
"studios": "Studios",
|
||||||
|
"network": "Netzwerk",
|
||||||
|
"currently_streaming_on": "Derzeit im Streaming auf",
|
||||||
|
"advanced": "Erweitert",
|
||||||
|
"request_as": "Anfragen als",
|
||||||
|
"tags": "Tags",
|
||||||
|
"quality_profile": "Qualitätsprofil",
|
||||||
|
"root_folder": "Root-Ordner",
|
||||||
|
"season_x": "Staffel {{seasons}}",
|
||||||
|
"season_number": "Staffel {{season_number}}",
|
||||||
|
"number_episodes": "{{episode_number}} Episodes",
|
||||||
|
"born": "Geboren",
|
||||||
|
"appearances": "Auftritte",
|
||||||
|
"toasts": {
|
||||||
|
"jellyseer_does_not_meet_requirements": "Jellyseerr Server erfüllt nicht die Anforderungsversion. Bitte aktualisiere deinen Jellyseerr Server auf mindestens 2.0.0",
|
||||||
|
"jellyseerr_test_failed": "Jellyseerr-Test fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
|
"failed_to_test_jellyseerr_server_url": "Fehler beim Testen der Jellyseerr-Server-URL",
|
||||||
|
"issue_submitted": "Problem eingereicht!",
|
||||||
|
"requested_item": "{{item}} angefragt!",
|
||||||
|
"you_dont_have_permission_to_request": "Du hast keine Berechtigung Anfragen zu stellen",
|
||||||
|
"something_went_wrong_requesting_media": "Etwas ist schiefgelaufen beim Anfragen von Medien"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"search": "Suche",
|
||||||
|
"library": "Bibliothek",
|
||||||
|
"custom_links": "Benutzerdefinierte Links",
|
||||||
|
"favorites": "Favoriten"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||||
"there_is_a_server_error": "There is a server error",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?"
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?"
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"storage": {
|
"storage": {
|
||||||
"storage_title": "Storage",
|
"storage_title": "Storage",
|
||||||
"app_usage": "App {{usedSpace}}%",
|
"app_usage": "App {{usedSpace}}%",
|
||||||
"phone_usage": "Phone {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete All Downloaded Files"
|
"delete_all_downloaded_files": "Delete All Downloaded Files"
|
||||||
},
|
},
|
||||||
|
|||||||
457
translations/es.json
Normal file
457
translations/es.json
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"username_required": "Se requiere un nombre de usuario",
|
||||||
|
"error_title": "Error",
|
||||||
|
"login_title": "Iniciar sesión",
|
||||||
|
"login_to_title": "Iniciar sesión en",
|
||||||
|
"username_placeholder": "Nombre de usuario",
|
||||||
|
"password_placeholder": "Contraseña",
|
||||||
|
"login_button": "Iniciar sesión",
|
||||||
|
"quick_connect": "Conexión rápida",
|
||||||
|
"enter_code_to_login": "Introduce el código {{code}} para iniciar sesión",
|
||||||
|
"failed_to_initiate_quick_connect": "Error al iniciar la conexión rápida",
|
||||||
|
"got_it": "Entendido",
|
||||||
|
"connection_failed": "Conexión fallida",
|
||||||
|
"could_not_connect_to_server": "No se pudo conectar al servidor. Por favor comprueba la URL y tu conexión de red.",
|
||||||
|
"an_unexpected_error_occured": "Ha ocurrido un error inesperado",
|
||||||
|
"change_server": "Cambiar servidor",
|
||||||
|
"invalid_username_or_password": "Usuario o contraseña inválidos",
|
||||||
|
"user_does_not_have_permission_to_log_in": "El usuario no tiene permiso para iniciar sesión",
|
||||||
|
"server_is_taking_too_long_to_respond_try_again_later": "El servidor está tardando mucho en responder, inténtalo de nuevo más tarde.",
|
||||||
|
"server_received_too_many_requests_try_again_later": "El servidor está recibiendo muchas peticiones, inténtalo de nuevo más tarde.",
|
||||||
|
"there_is_a_server_error": "Hay un error en el servidor",
|
||||||
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"enter_url_to_jellyfin_server": "Introduce la URL de tu servidor Jellyfin",
|
||||||
|
"server_url_placeholder": "http(s)://tu-servidor.com",
|
||||||
|
"connect_button": "Conectar",
|
||||||
|
"previous_servers": "Servidores previos",
|
||||||
|
"clear_button": "Limpiar",
|
||||||
|
"search_for_local_servers": "Buscar servidores locales",
|
||||||
|
"searching": "Buscando...",
|
||||||
|
"servers": "Servidores"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"no_internet": "Sin internet",
|
||||||
|
"no_items": "No hay ítems",
|
||||||
|
"no_internet_message": "No te preocupes, todavía puedes\nver el contenido descargado.",
|
||||||
|
"go_to_downloads": "Ir a descargas",
|
||||||
|
"oops": "¡Vaya!",
|
||||||
|
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
|
||||||
|
"continue_watching": "Seguir viendo",
|
||||||
|
"next_up": "A continuación",
|
||||||
|
"recently_added_in": "Recientemente añadido en {{libraryName}}",
|
||||||
|
"suggested_movies": "Películas sugeridas",
|
||||||
|
"suggested_episodes": "Episodios sugeridos",
|
||||||
|
"intro": {
|
||||||
|
"welcome_to_streamyfin": "Bienvenido a Streamyfin",
|
||||||
|
"a_free_and_open_source_client_for_jellyfin": "Un cliente gratuito y de código abierto para Jellyfin.",
|
||||||
|
"features_title": "Características",
|
||||||
|
"features_description": "Streamyfin tiene una amplia gama de características y se integra con una variedad de software que puedes encontrar en el menú de configuración, esto incluye:",
|
||||||
|
"jellyseerr_feature_description": "Conéctate a tu servidor de Jellyseer y pide películas directamente desde la app.",
|
||||||
|
"downloads_feature_title": "Descargas",
|
||||||
|
"downloads_feature_description": "Descarga películas y series para ver sin conexión. Usa el método por defecto o el servidor optimizado para descargar archivos en segundo plano.",
|
||||||
|
"chromecast_feature_description": "Envía pelícuas y series a tus dispositivos Chromecast.",
|
||||||
|
"centralised_settings_plugin_title": "Plugin de configuración centralizada",
|
||||||
|
"centralised_settings_plugin_description": "Crea configuraciones desde una ubicación centralizada en tu servidor de Jellyfin. Todas las configuraciones para todos los usuarios se sincronizarán automáticamente.",
|
||||||
|
"done_button": "Hecho",
|
||||||
|
"go_to_settings_button": "Ir a la configuración",
|
||||||
|
"read_more": "Leer más"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings_title": "Configuración",
|
||||||
|
"log_out_button": "Cerrar sesión",
|
||||||
|
"user_info": {
|
||||||
|
"user_info_title": "Información de usuario",
|
||||||
|
"user": "Usuario",
|
||||||
|
"server": "Servidor",
|
||||||
|
"token": "Token",
|
||||||
|
"app_version": "Versión de la app"
|
||||||
|
},
|
||||||
|
"quick_connect": {
|
||||||
|
"quick_connect_title": "Conexión rápida",
|
||||||
|
"authorize_button": "Autorizar conexión rápida",
|
||||||
|
"enter_the_quick_connect_code": "Introduce el código de conexión rápida...",
|
||||||
|
"success": "Hecho",
|
||||||
|
"quick_connect_autorized": "Conexión rápida autorizada",
|
||||||
|
"error": "Error",
|
||||||
|
"invalid_code": "Código inválido",
|
||||||
|
"authorize": "Autorizar"
|
||||||
|
},
|
||||||
|
"media_controls": {
|
||||||
|
"media_controls_title": "Controles de reproducción",
|
||||||
|
"forward_skip_length": "Longitud de avance",
|
||||||
|
"rewind_length": "Longitud de retroceso",
|
||||||
|
"seconds_unit": "s"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"audio_title": "Audio",
|
||||||
|
"set_audio_track": "Establecer pista de audio del elemento anterior",
|
||||||
|
"audio_language": "Idioma de audio",
|
||||||
|
"audio_hint": "Elige un idioma de audio por defecto.",
|
||||||
|
"none": "Ninguno",
|
||||||
|
"language": "Idioma"
|
||||||
|
},
|
||||||
|
"subtitles": {
|
||||||
|
"subtitle_title": "Subtítulos",
|
||||||
|
"subtitle_language": "Idioma de subtítulos",
|
||||||
|
"subtitle_mode": "Modo de subtítulos",
|
||||||
|
"set_subtitle_track": "Establecer pista de subtítulos del elemento anterior",
|
||||||
|
"subtitle_size": "Tamaño de subtítulos",
|
||||||
|
"subtitle_hint": "Configurar preferencias de subtítulos.",
|
||||||
|
"none": "Ninguno",
|
||||||
|
"language": "Idioma",
|
||||||
|
"loading": "Cargando",
|
||||||
|
"modes": {
|
||||||
|
"Default": "Por defecto",
|
||||||
|
"Smart": "Inteligente",
|
||||||
|
"Always": "Siempre",
|
||||||
|
"None": "Nada",
|
||||||
|
"OnlyForced": "Solo forzados"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"other_title": "Otros",
|
||||||
|
"auto_rotate": "Rotación automática",
|
||||||
|
"video_orientation": "Orientación de vídeo",
|
||||||
|
"orientation": "Orientación",
|
||||||
|
"orientations": {
|
||||||
|
"DEFAULT": "Por defecto",
|
||||||
|
"ALL": "Todas",
|
||||||
|
"PORTRAIT": "Vertical",
|
||||||
|
"PORTRAIT_UP": "Vertical arriba",
|
||||||
|
"PORTRAIT_DOWN": "Vertical abajo",
|
||||||
|
"LANDSCAPE": "Horizontal",
|
||||||
|
"LANDSCAPE_LEFT": "Horizontal izquierda",
|
||||||
|
"LANDSCAPE_RIGHT": "Horizontal derecha",
|
||||||
|
"OTHER": "Otra",
|
||||||
|
"UNKNOWN": "Desconocida"
|
||||||
|
},
|
||||||
|
"safe_area_in_controls": "Área segura en controles",
|
||||||
|
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
||||||
|
"hide_libraries": "Ocultar bibliotecas",
|
||||||
|
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
||||||
|
"disable_haptic_feedback": "Desactivar feedback háptico"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Descargas",
|
||||||
|
"download_method": "Método de descarga",
|
||||||
|
"remux_max_download": "Remux máx. descarga",
|
||||||
|
"auto_download": "Descarga automática",
|
||||||
|
"optimized_versions_server": "Servidor de versiones optimizadas",
|
||||||
|
"save_button": "Guardar",
|
||||||
|
"optimized_server": "Servidor optimizado",
|
||||||
|
"optimized": "Optimizado",
|
||||||
|
"default": "Por defecto",
|
||||||
|
"optimized_version_hint": "Introduce la URL del servidor de versiones optimizadas. La URL debe incluir http o https y opcionalmente el puerto.",
|
||||||
|
"read_more_about_optimized_server": "Leer más sobre el servidor de versiones optimizadas.",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://dominio.org:puerto"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"plugins_title": "Plugins",
|
||||||
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "Esta integración está en sus primeras etapas. Cuenta con posibles cambios.",
|
||||||
|
"server_url": "URL del servidor",
|
||||||
|
"server_url_hint": "Ejemplo: http(s)://tu-dominio.url\n(añade el puerto si es necesario)",
|
||||||
|
"server_url_placeholder": "URL de Jellyseerr...",
|
||||||
|
"password": "Contrasñea",
|
||||||
|
"password_placeholder": "Introduce la contraseña de Jellyfin de {{username}}",
|
||||||
|
"save_button": "Guardar",
|
||||||
|
"clear_button": "Limpiar",
|
||||||
|
"login_button": "Iniciar sesión",
|
||||||
|
"total_media_requests": "Peticiones totales de medios",
|
||||||
|
"movie_quota_limit": "Límite de cuota de películas",
|
||||||
|
"movie_quota_days": "Días de cuota de películas",
|
||||||
|
"tv_quota_limit": "Límite de cuota de series",
|
||||||
|
"tv_quota_days": "Días de cuota de series",
|
||||||
|
"reset_jellyseerr_config_button": "Restablecer configuración de Jellyseerr",
|
||||||
|
"unlimited": "Ilimitado"
|
||||||
|
},
|
||||||
|
"marlin_search": {
|
||||||
|
"enable_marlin_search": "Habilitar búsqueda de Marlin",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://dominio.org:puerto",
|
||||||
|
"marlin_search_hint": "Introduce la URL del servidor de Marlin. La URL debe incluir http o https y opcionalmente el puerto.",
|
||||||
|
"read_more_about_marlin": "Leer más sobre Marlin.",
|
||||||
|
"save_button": "Guardar",
|
||||||
|
"toasts": {
|
||||||
|
"saved": "Guardado"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"storage_title": "Almacenamiento",
|
||||||
|
"app_usage": "App {{usedSpace}}%",
|
||||||
|
"device_usage": "Dispositivo {{availableSpace}}%",
|
||||||
|
"size_used": "{{used}} de {{total}} usado",
|
||||||
|
"delete_all_downloaded_files": "Eliminar todos los archivos descargados"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"show_intro": "Mostrar intro",
|
||||||
|
"reset_intro": "Restablecer intro"
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"logs_title": "Registros",
|
||||||
|
"no_logs_available": "No hay registros disponibles",
|
||||||
|
"delete_all_logs": "Eliminar todos los registros"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"title": "Idiomas",
|
||||||
|
"app_language": "Idioma de la app",
|
||||||
|
"app_language_description": "Selecciona el idioma de la app.",
|
||||||
|
"system": "Sistema"
|
||||||
|
},
|
||||||
|
"toasts":{
|
||||||
|
"error_deleting_files": "Error al eliminar archivos",
|
||||||
|
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
|
||||||
|
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas",
|
||||||
|
"connected": "Conectado",
|
||||||
|
"could_not_connect": "No se pudo conectar",
|
||||||
|
"invalid_url": "URL inválida"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Descargas",
|
||||||
|
"tvseries": "Series",
|
||||||
|
"movies": "Películas",
|
||||||
|
"queue": "Cola",
|
||||||
|
"queue_hint": "La cola de series y películas se perderá al reiniciar la app",
|
||||||
|
"no_items_in_queue": "No hay ítems en la cola",
|
||||||
|
"no_downloaded_items": "No hay ítems descargados",
|
||||||
|
"delete_all_movies_button": "Eliminar todas las películas",
|
||||||
|
"delete_all_tvseries_button": "Eliminar todas las series",
|
||||||
|
"delete_all_button": "Eliminar todo",
|
||||||
|
"active_download": "Descarga activa",
|
||||||
|
"no_active_downloads": "No hay descargas activas",
|
||||||
|
"active_downloads": "Descargas activas",
|
||||||
|
"new_app_version_requires_re_download": "La nueva actualización requiere volver a descargar",
|
||||||
|
"new_app_version_requires_re_download_description": "La nueva actualización requiere volver a descargar el contenido. Por favor, elimina todo el código descargado y vuélvelo a intentar.",
|
||||||
|
"back": "Atrás",
|
||||||
|
"delete": "Borrar",
|
||||||
|
"something_went_wrong": "Algo ha salido mal",
|
||||||
|
"could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin",
|
||||||
|
"eta": "{{eta}} restante",
|
||||||
|
"methods": "Métodos",
|
||||||
|
"toasts": {
|
||||||
|
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
|
||||||
|
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
|
||||||
|
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
|
||||||
|
"deleted_all_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
|
||||||
|
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
|
||||||
|
"download_cancelled": "Descarga cancelada",
|
||||||
|
"could_not_cancel_download": "No se pudo cancelar la descarga",
|
||||||
|
"download_completed": "Descarga completada",
|
||||||
|
"download_started_for": "Descarga iniciada para {{item}}",
|
||||||
|
"item_is_ready_to_be_downloaded": "{{item}} está listo para ser descargado",
|
||||||
|
"download_stated_for_item": "Descarga iniciada para {{item}}",
|
||||||
|
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
||||||
|
"download_completed_for_item": "Descarga completada para {{item}}",
|
||||||
|
"queued_item_for_optimization": "{{item}} en cola para optimización",
|
||||||
|
"failed_to_start_download_for_item": "Error al iniciar la descarga para {{item}}: {{message}}",
|
||||||
|
"server_responded_with_status_code": "El servidor ha respondido con el estado {{statusCode}}",
|
||||||
|
"no_response_received_from_server": "No se ha recibido respuesta del servidor",
|
||||||
|
"error_setting_up_the_request": "Error al configurar la petición",
|
||||||
|
"failed_to_start_download_for_item_unexpected_error": "Error al iniciar la descarga para {{item}}: Error inesperado",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Todos los archivos, carpetas y trabajos eliminados con éxito",
|
||||||
|
"an_error_occured_while_deleting_files_and_jobs": "Ha ocurrido un error al eliminar archivos y trabajos",
|
||||||
|
"go_to_downloads": "Ir a descargas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"search_here": "Buscar aquí...",
|
||||||
|
"search": "Buscar...",
|
||||||
|
"x_items": "{{count}} ítems",
|
||||||
|
"library": "Biblioteca",
|
||||||
|
"discover": "Descubrir",
|
||||||
|
"no_results": "Sin resultados",
|
||||||
|
"no_results_found_for": "No se han encontrado resultados para",
|
||||||
|
"movies": "Películas",
|
||||||
|
"series": "Series",
|
||||||
|
"episodes": "Episodios",
|
||||||
|
"collections": "Colecciones",
|
||||||
|
"actors": "Actores",
|
||||||
|
"request_movies": "Solicitar películas",
|
||||||
|
"request_series": "Solicitar series",
|
||||||
|
"recently_added": "Recientemente añadido",
|
||||||
|
"recent_requests": "Solicitudes recientes",
|
||||||
|
"plex_watchlist": "Lista de seguimiento de Plex",
|
||||||
|
"trending": "Trending",
|
||||||
|
"popular_movies": "Películas populares",
|
||||||
|
"movie_genres": "Géneros de películas",
|
||||||
|
"upcoming_movies": "Próximas películas",
|
||||||
|
"studios": "Estudios",
|
||||||
|
"popular_tv": "Series populares",
|
||||||
|
"tv_genres": "Géneros de series",
|
||||||
|
"upcoming_tv": "Próximas series",
|
||||||
|
"networks": "Cadenas",
|
||||||
|
"tmdb_movie_keyword": "Palabra clave de película de TMDB",
|
||||||
|
"tmdb_movie_genre": "Género de película de TMDB",
|
||||||
|
"tmdb_tv_keyword": "Palabra clave de serie de TMDB",
|
||||||
|
"tmdb_tv_genre": "Género de serie de TMDB",
|
||||||
|
"tmdb_search": "Búsqueda de TMDB",
|
||||||
|
"tmdb_studio": "Estudio de TMDB",
|
||||||
|
"tmdb_network": "Cadena de TMDB",
|
||||||
|
"tmdb_movie_streaming_services": "Servicios de streaming de películas de TMDB",
|
||||||
|
"tmdb_tv_streaming_services": "Servicios de streaming de series de TMDB"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"no_items_found": "No se han encontrado ítems",
|
||||||
|
"no_results": "Sin resultados",
|
||||||
|
"no_libraries_found": "No se han encontrado bibliotecas",
|
||||||
|
"item_types": {
|
||||||
|
"movies": "películas",
|
||||||
|
"series": "series",
|
||||||
|
"boxsets": "colecciones",
|
||||||
|
"items": "ítems"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"display": "Mostrar",
|
||||||
|
"row": "Fila",
|
||||||
|
"list": "Lista",
|
||||||
|
"image_style": "Estilo de imagen",
|
||||||
|
"poster": "Poster",
|
||||||
|
"cover": "Portada",
|
||||||
|
"show_titles": "Mostrar títulos",
|
||||||
|
"show_stats": "Mostrar estadísticas"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"genres": "Géneros",
|
||||||
|
"years": "Años",
|
||||||
|
"sort_by": "Ordenar por",
|
||||||
|
"sort_order": "Ordenar",
|
||||||
|
"tags": "Etiquetas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"series": "Series",
|
||||||
|
"movies": "Películas",
|
||||||
|
"episodes": "Episodios",
|
||||||
|
"videos": "Vídeos",
|
||||||
|
"boxsets": "Colecciones",
|
||||||
|
"playlists": "Playlists"
|
||||||
|
},
|
||||||
|
"custom_links": {
|
||||||
|
"no_links": "Sin enlaces"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Error",
|
||||||
|
"failed_to_get_stream_url": "Error al obtener la URL del stream",
|
||||||
|
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
|
||||||
|
"client_error": "Error del cliente",
|
||||||
|
"could_not_create_stream_for_chromecast": "No se pudo crear el stream para Chromecast",
|
||||||
|
"message_from_server": "Mensaje del servidor: {{message}}",
|
||||||
|
"video_has_finished_playing": "El vídeo ha terminado de reproducirse",
|
||||||
|
"no_video_source": "No hay fuente de vídeo...",
|
||||||
|
"next_episode": "Siguiente episodio",
|
||||||
|
"refresh_tracks": "Refrescar pistas",
|
||||||
|
"subtitle_tracks": "Pistas de subtítulos:",
|
||||||
|
"audio_tracks": "Pistas de audio:",
|
||||||
|
"playback_state": "Estado de la reproducción:",
|
||||||
|
"no_data_available": "No hay datos disponibles",
|
||||||
|
"index": "Índice:"
|
||||||
|
},
|
||||||
|
"item_card": {
|
||||||
|
"next_up": "A continuación",
|
||||||
|
"no_items_to_display": "No hay ítems para mostrar",
|
||||||
|
"cast_and_crew": "Reparto y equipo",
|
||||||
|
"series": "Series",
|
||||||
|
"seasons": "Temporadas",
|
||||||
|
"season": "Temporada",
|
||||||
|
"no_episodes_for_this_season": "No hay episodios para esta temporada",
|
||||||
|
"overview": "Resumen",
|
||||||
|
"more_with": "Más con {{name}}",
|
||||||
|
"similar_items": "Ítems similares",
|
||||||
|
"no_similar_items_found": "No se han encontrado ítems similares",
|
||||||
|
"video": "Vídeo",
|
||||||
|
"more_details": "Más detalles",
|
||||||
|
"quality": "Calidad",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Subtítulos",
|
||||||
|
"show_more": "Mostrar más",
|
||||||
|
"show_less": "Mostrar menos",
|
||||||
|
"appeared_in": "Apareció en",
|
||||||
|
"could_not_load_item": "No se pudo cargar el ítem",
|
||||||
|
"none": "Ninguno",
|
||||||
|
"download": {
|
||||||
|
"download_season": "Descargar temporada",
|
||||||
|
"download_series": "Descargar serie",
|
||||||
|
"download_episode": "Descargar episodio",
|
||||||
|
"download_movie": "Descargar película",
|
||||||
|
"download_x_item": "Descargar {{item_count}} ítems",
|
||||||
|
"download_button": "Descargar",
|
||||||
|
"using_optimized_server": "Usando servidor optimizado",
|
||||||
|
"using_default_method": "Usando método por defecto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"live_tv": {
|
||||||
|
"next": "Siguiente",
|
||||||
|
"previous": "Anterior",
|
||||||
|
"live_tv": "TV en directo",
|
||||||
|
"coming_soon": "Próximamente",
|
||||||
|
"on_now": "En directo",
|
||||||
|
"shows": "Programas",
|
||||||
|
"movies": "Películas",
|
||||||
|
"sports": "Deportes",
|
||||||
|
"for_kids": "Para niños",
|
||||||
|
"news": "Noticias"
|
||||||
|
},
|
||||||
|
"jellyseerr":{
|
||||||
|
"confirm": "Confirmar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"yes": "Sí",
|
||||||
|
"whats_wrong": "¿Qué pasa?",
|
||||||
|
"issue_type": "Tipo de problema",
|
||||||
|
"select_an_issue": "Selecciona un problema",
|
||||||
|
"types": "Tipos",
|
||||||
|
"describe_the_issue": "(opcional) Describe el problema...",
|
||||||
|
"submit_button": "Enviar",
|
||||||
|
"report_issue_button": "Reportar problema",
|
||||||
|
"request_button": "Solicitar",
|
||||||
|
"are_you_sure_you_want_to_request_all_seasons": "¿Estás seguro de que quieres solicitar todas las temporadas?",
|
||||||
|
"failed_to_login": "Error al iniciar sesión",
|
||||||
|
"cast": "Reparto",
|
||||||
|
"details": "Detalles",
|
||||||
|
"status": "Estado",
|
||||||
|
"original_title": "Título original",
|
||||||
|
"series_type": "Tipo de serie",
|
||||||
|
"release_dates": "Fechas de estreno",
|
||||||
|
"first_air_date": "Primera fecha de emisión",
|
||||||
|
"next_air_date": "Próxima fecha de emisión",
|
||||||
|
"revenue": "Ingresos",
|
||||||
|
"budget": "Presupuesto",
|
||||||
|
"original_language": "Idioma original",
|
||||||
|
"production_country": "País de producción",
|
||||||
|
"studios": "Estudios",
|
||||||
|
"network": "Cadena",
|
||||||
|
"currently_streaming_on": "Actualmente en streaming en",
|
||||||
|
"advanced": "Avanzado",
|
||||||
|
"request_as": "Solicitar como",
|
||||||
|
"tags": "Etiquetas",
|
||||||
|
"quality_profile": "Perfil de calidad",
|
||||||
|
"root_folder": "Carpeta raíz",
|
||||||
|
"season_x": "Temporada {{seasons}}",
|
||||||
|
"season_number": "Temporada {{season_number}}",
|
||||||
|
"number_episodes": "{{episode_number}} episodios",
|
||||||
|
"born": "Nacido",
|
||||||
|
"appearances": "Apariciones",
|
||||||
|
"toasts": {
|
||||||
|
"jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.",
|
||||||
|
"jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.",
|
||||||
|
"failed_to_test_jellyseerr_server_url": "Error al probar la URL del servidor de Jellyseerr",
|
||||||
|
"issue_submitted": "¡Problema enviado!",
|
||||||
|
"requested_item": "¡{{item}} solicitado!",
|
||||||
|
"you_dont_have_permission_to_request": "¡No tienes permiso para solicitar!",
|
||||||
|
"something_went_wrong_requesting_media": "¡Algo ha salido mal solicitando los medios!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"home": "Inicio",
|
||||||
|
"search": "Buscar",
|
||||||
|
"library": "Bibliotecas",
|
||||||
|
"custom_links": "Enlaces personalizados",
|
||||||
|
"favorites": "Favoritos"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,19 +11,19 @@
|
|||||||
"enter_code_to_login": "Entrez le code {{code}} pour vous connecter",
|
"enter_code_to_login": "Entrez le code {{code}} pour vous connecter",
|
||||||
"failed_to_initiate_quick_connect": "Échec de l'initialisation de Connexion Rapide",
|
"failed_to_initiate_quick_connect": "Échec de l'initialisation de Connexion Rapide",
|
||||||
"got_it": "D'accord",
|
"got_it": "D'accord",
|
||||||
"connection_failed": "La connection a échouée",
|
"connection_failed": "La connexion a échoué",
|
||||||
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau.",
|
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau.",
|
||||||
"an_unexpected_error_occured": "Une erreur inattendue s'est produite",
|
"an_unexpected_error_occured": "Une erreur inattendue s'est produite",
|
||||||
"change_server": "Changer de serveur",
|
"change_server": "Changer de serveur",
|
||||||
"invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
|
"invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
|
||||||
"user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter",
|
"user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter",
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Le serveur met trop de temps à répondre, réessayez plus tard",
|
"server_is_taking_too_long_to_respond_try_again_later": "Le serveur prend trop de temps à répondre, réessayez plus tard",
|
||||||
"server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard",
|
"server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard",
|
||||||
"there_is_a_server_error": "Il y a une erreur de serveur",
|
"there_is_a_server_error": "Il y a une erreur de serveur",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue s'est produite. Avez-vous entré la bonne URL?"
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue s'est produite. Avez-vous entré la bonne URL?"
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Entrez l'URL de votre serveur Jellyfin",
|
"enter_url_to_jellyfin_server": "Entrez l'URL du serveur Jellyfin",
|
||||||
"server_url_placeholder": "http(s)://votre-serveur.com",
|
"server_url_placeholder": "http(s)://votre-serveur.com",
|
||||||
"connect_button": "Connexion",
|
"connect_button": "Connexion",
|
||||||
"previous_servers": "Serveurs précédents",
|
"previous_servers": "Serveurs précédents",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"no_internet": "Pas d'Internet",
|
"no_internet": "Pas d'Internet",
|
||||||
"no_items": "Aucun item",
|
"no_items": "Aucun média",
|
||||||
"no_internet_message": "Aucun problème, vous pouvez toujours regarder\nle contenu téléchargé.",
|
"no_internet_message": "Aucun problème, vous pouvez toujours regarder\nle contenu téléchargé.",
|
||||||
"go_to_downloads": "Aller aux téléchargements",
|
"go_to_downloads": "Aller aux téléchargements",
|
||||||
"oops": "Oups!",
|
"oops": "Oups!",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"chromecast_feature_description": "Diffusez des films et des émissions de télévision sur vos appareils Chromecast.",
|
"chromecast_feature_description": "Diffusez des films et des émissions de télévision sur vos appareils Chromecast.",
|
||||||
"centralised_settings_plugin_title": "Plugin de paramètres centralisés",
|
"centralised_settings_plugin_title": "Plugin de paramètres centralisés",
|
||||||
"centralised_settings_plugin_description": "Configuration des paramètres d'un emplacement centralisé sur votre serveur Jellyfin. Tous les paramètres clients pour tous les utilisateurs seront synchronisés automatiquement.",
|
"centralised_settings_plugin_description": "Configuration des paramètres d'un emplacement centralisé sur votre serveur Jellyfin. Tous les paramètres clients pour tous les utilisateurs seront synchronisés automatiquement.",
|
||||||
"done_button": "Fait",
|
"done_button": "Terminé",
|
||||||
"go_to_settings_button": "Allez dans les paramètres",
|
"go_to_settings_button": "Allez dans les paramètres",
|
||||||
"read_more": "Lisez-en plus"
|
"read_more": "Lisez-en plus"
|
||||||
},
|
},
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"media_controls": {
|
"media_controls": {
|
||||||
"media_controls_title": "Contrôles Média",
|
"media_controls_title": "Contrôles Média",
|
||||||
"forward_skip_length": "Durée de saut en avant",
|
"forward_skip_length": "Durée de saut en avant",
|
||||||
"rewind_length": "Durée de retour arrière",
|
"rewind_length": "Durée de retour en arrière",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"Smart": "Intelligent",
|
"Smart": "Intelligent",
|
||||||
"Always": "Toujours",
|
"Always": "Toujours",
|
||||||
"None": "Aucun",
|
"None": "Aucun",
|
||||||
"OnlyForced": "Forcés seulement"
|
"OnlyForced": "Forcés seulement"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
||||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||||
"hide_libraries": "Cacher des bibliothèques",
|
"hide_libraries": "Cacher des bibliothèques",
|
||||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez obtenir de la table de bibliothèque et de la page d'accueil des sections.",
|
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l’onglet Bibliothèque et les sections de la page d’accueil.",
|
||||||
"disable_haptic_feedback": "Désactiver le retour haptique"
|
"disable_haptic_feedback": "Désactiver le retour haptique"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
"server_url_placeholder": "http(s)://domaine.org:port"
|
"server_url_placeholder": "http(s)://domaine.org:port"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugiciels",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"jellyseerr_warning": "Cette intégration est dans ses débuts. Attendez-vous à ce que des choses changent.",
|
"jellyseerr_warning": "Cette intégration est dans ses débuts. Attendez-vous à ce que des choses changent.",
|
||||||
"server_url": "URL du serveur",
|
"server_url": "URL du serveur",
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"storage": {
|
"storage": {
|
||||||
"storage_title": "Stockage",
|
"storage_title": "Stockage",
|
||||||
"app_usage": "App {{usedSpace}}%",
|
"app_usage": "App {{usedSpace}}%",
|
||||||
"phone_usage": "Téléphone {{availableSpace}}%",
|
"device_usage": "Appareil {{availableSpace}}%",
|
||||||
"size_used": "{{used}} de {{total}} utilisés",
|
"size_used": "{{used}} de {{total}} utilisés",
|
||||||
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés"
|
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés"
|
||||||
},
|
},
|
||||||
@@ -218,11 +218,11 @@
|
|||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"queue": "File d'attente",
|
"queue": "File d'attente",
|
||||||
"queue_hint": "La file d'attente et les téléchargements seront perdus au redémarrage de l'application",
|
"queue_hint": "La file d'attente et les téléchargements seront perdus au redémarrage de l'application",
|
||||||
"no_items_in_queue": "Aucun item dans la file d'attente",
|
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
|
||||||
"no_downloaded_items": "Aucun item téléchargé",
|
"no_downloaded_items": "Aucun média téléchargé",
|
||||||
"delete_all_movies_button": "Supprimer tous les films",
|
"delete_all_movies_button": "Supprimer tous les films",
|
||||||
"delete_all_tvseries_button": "Supprimer toutes les séries",
|
"delete_all_tvseries_button": "Supprimer toutes les séries",
|
||||||
"delete_all_button": "Supprimer tout",
|
"delete_all_button": "Supprimer tout les médias",
|
||||||
"active_download": "Téléchargement actif",
|
"active_download": "Téléchargement actif",
|
||||||
"no_active_downloads": "Aucun téléchargements actifs",
|
"no_active_downloads": "Aucun téléchargements actifs",
|
||||||
"active_downloads": "Téléchargements actifs",
|
"active_downloads": "Téléchargements actifs",
|
||||||
@@ -254,8 +254,8 @@
|
|||||||
"no_response_received_from_server": "Aucune réponse reçue du serveur",
|
"no_response_received_from_server": "Aucune réponse reçue du serveur",
|
||||||
"error_setting_up_the_request": "Erreur lors de la configuration de la demande",
|
"error_setting_up_the_request": "Erreur lors de la configuration de la demande",
|
||||||
"failed_to_start_download_for_item_unexpected_error": "Échec du démarrage du téléchargement pour {{item}}: Erreur inattendue",
|
"failed_to_start_download_for_item_unexpected_error": "Échec du démarrage du téléchargement pour {{item}}: Erreur inattendue",
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Tous les fichiers, dossiers et travaux ont été supprimés avec succès",
|
"all_files_folders_and_jobs_deleted_successfully": "Tous les fichiers, dossiers et tâches ont été supprimés avec succès",
|
||||||
"an_error_occured_while_deleting_files_and_jobs": "Une erreur s'est produite lors de la suppression des fichiers et des travaux",
|
"an_error_occured_while_deleting_files_and_jobs": "Une erreur s'est produite lors de la suppression des fichiers et des tâches",
|
||||||
"go_to_downloads": "Aller aux téléchargements"
|
"go_to_downloads": "Aller aux téléchargements"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
"search": {
|
"search": {
|
||||||
"search_here": "Rechercher ici...",
|
"search_here": "Rechercher ici...",
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
"x_items": "{{count}} items",
|
"x_items": "{{count}} médias",
|
||||||
"library": "Bibliothèque",
|
"library": "Bibliothèque",
|
||||||
"discover": "Découvrir",
|
"discover": "Découvrir",
|
||||||
"no_results": "Aucun résultat",
|
"no_results": "Aucun résultat",
|
||||||
@@ -287,9 +287,9 @@
|
|||||||
"tv_genres": "Genres TV",
|
"tv_genres": "Genres TV",
|
||||||
"upcoming_tv": "TV à venir",
|
"upcoming_tv": "TV à venir",
|
||||||
"networks": "Réseaux",
|
"networks": "Réseaux",
|
||||||
"tmdb_movie_keyword": "Mot-clé Films TMDB",
|
"tmdb_movie_keyword": "Mot(s)-clé(s) Films TMDB",
|
||||||
"tmdb_movie_genre": "Genre de film TMDB",
|
"tmdb_movie_genre": "Genre de film TMDB",
|
||||||
"tmdb_tv_keyword": "Mot-clé TV TMDB",
|
"tmdb_tv_keyword": "Mot(s)-clé(s) TV TMDB",
|
||||||
"tmdb_tv_genre": "Genre TV TMDB",
|
"tmdb_tv_genre": "Genre TV TMDB",
|
||||||
"tmdb_search": "Recherche TMDB",
|
"tmdb_search": "Recherche TMDB",
|
||||||
"tmdb_studio": "Studio TMDB",
|
"tmdb_studio": "Studio TMDB",
|
||||||
@@ -298,14 +298,14 @@
|
|||||||
"tmdb_tv_streaming_services": "Services de streaming TV TMDB"
|
"tmdb_tv_streaming_services": "Services de streaming TV TMDB"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"no_items_found": "Aucun item trouvé",
|
"no_items_found": "Aucun média trouvé",
|
||||||
"no_results": "Aucun résultat",
|
"no_results": "Aucun résultat",
|
||||||
"no_libraries_found": "Aucune bibliothèque trouvée",
|
"no_libraries_found": "Aucune bibliothèque trouvée",
|
||||||
"item_types": {
|
"item_types": {
|
||||||
"movies": "films",
|
"movies": "films",
|
||||||
"series": "séries",
|
"series": "séries",
|
||||||
"boxsets": "coffrets",
|
"boxsets": "coffrets",
|
||||||
"items": "items"
|
"items": "médias"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"display": "Affichage",
|
"display": "Affichage",
|
||||||
@@ -332,16 +332,16 @@
|
|||||||
"videos": "Vidéos",
|
"videos": "Vidéos",
|
||||||
"boxsets": "Coffrets",
|
"boxsets": "Coffrets",
|
||||||
"playlists": "Listes de lecture"
|
"playlists": "Listes de lecture"
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "Aucun lien"
|
"no_links": "Aucuns liens"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"error": "Erreur",
|
"error": "Erreur",
|
||||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||||
"an_error_occured_while_playing_the_video": "Une erreur s'est produite lors de la lecture de la vidéo",
|
"an_error_occured_while_playing_the_video": "Une erreur s'est produite lors de la lecture de la vidéo",
|
||||||
"client_error": "Erreur client",
|
"client_error": "Erreur client",
|
||||||
"could_not_create_stream_for_chromecast": "Impossible de créer un flux pour Chromecast",
|
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
|
||||||
"message_from_server": "Message du serveur: {{message}}",
|
"message_from_server": "Message du serveur: {{message}}",
|
||||||
"video_has_finished_playing": "La vidéo a fini de jouer!",
|
"video_has_finished_playing": "La vidéo a fini de jouer!",
|
||||||
"no_video_source": "Aucune source vidéo...",
|
"no_video_source": "Aucune source vidéo...",
|
||||||
@@ -355,7 +355,7 @@
|
|||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "À suivre",
|
"next_up": "À suivre",
|
||||||
"no_items_to_display": "Aucun item à afficher",
|
"no_items_to_display": "Aucun médias à afficher",
|
||||||
"cast_and_crew": "Distribution et équipe",
|
"cast_and_crew": "Distribution et équipe",
|
||||||
"series": "Séries",
|
"series": "Séries",
|
||||||
"seasons": "Saisons",
|
"seasons": "Saisons",
|
||||||
@@ -363,8 +363,8 @@
|
|||||||
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
||||||
"overview": "Aperçu",
|
"overview": "Aperçu",
|
||||||
"more_with": "Plus avec {{name}}",
|
"more_with": "Plus avec {{name}}",
|
||||||
"similar_items": "Items similaires",
|
"similar_items": "Médias similaires",
|
||||||
"no_similar_items_found": "Aucun item similaire trouvé",
|
"no_similar_items_found": "Aucun média similaire trouvé",
|
||||||
"video": "Vidéo",
|
"video": "Vidéo",
|
||||||
"more_details": "Plus de détails",
|
"more_details": "Plus de détails",
|
||||||
"quality": "Qualité",
|
"quality": "Qualité",
|
||||||
@@ -373,18 +373,18 @@
|
|||||||
"show_more": "Afficher plus",
|
"show_more": "Afficher plus",
|
||||||
"show_less": "Afficher moins",
|
"show_less": "Afficher moins",
|
||||||
"appeared_in": "Apparu dans",
|
"appeared_in": "Apparu dans",
|
||||||
"could_not_load_item": "Impossible de charger l'item",
|
"could_not_load_item": "Impossible de charger le média",
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"download": {
|
"download": {
|
||||||
"download_season": "Télécharger la saison",
|
"download_season": "Télécharger la saison",
|
||||||
"download_series": "Télécharger la série",
|
"download_series": "Télécharger la série",
|
||||||
"download_episode": "Télécharger l'épisode",
|
"download_episode": "Télécharger l'épisode",
|
||||||
"download_movie": "Télécharger le film",
|
"download_movie": "Télécharger le film",
|
||||||
"download_x_item": "Télécharger {{item_count}} items",
|
"download_x_item": "Télécharger {{item_count}} médias",
|
||||||
"download_button": "Télécharger",
|
"download_button": "Télécharger",
|
||||||
"using_optimized_server": "Avec le serveur de versions optimisées",
|
"using_optimized_server": "Avec le serveur optimisées",
|
||||||
"using_default_method": "Avec la méthode par défaut"
|
"using_default_method": "Avec la méthode par défaut"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"yes": "Oui",
|
"yes": "Oui",
|
||||||
"whats_wrong": "Qu'est-ce qui ne va pas?",
|
"whats_wrong": "Quel est le problème?",
|
||||||
"issue_type": "Type de problème",
|
"issue_type": "Type de problème",
|
||||||
"select_an_issue": "Sélectionnez un problème",
|
"select_an_issue": "Sélectionnez un problème",
|
||||||
"types": "Types",
|
"types": "Types",
|
||||||
@@ -426,7 +426,7 @@
|
|||||||
"production_country": "Pays de production",
|
"production_country": "Pays de production",
|
||||||
"studios": "Studios",
|
"studios": "Studios",
|
||||||
"network": "Réseaux",
|
"network": "Réseaux",
|
||||||
"currently_streaming_on": "En diffusion continue sur",
|
"currently_streaming_on": "En streaming sur",
|
||||||
"advanced": "Avancé",
|
"advanced": "Avancé",
|
||||||
"request_as": "Demander en tant que",
|
"request_as": "Demander en tant que",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
"season_number": "Saison {{season_number}}",
|
"season_number": "Saison {{season_number}}",
|
||||||
"number_episodes": "{{episode_number}} épisodes",
|
"number_episodes": "{{episode_number}} épisodes",
|
||||||
"born": "Né(e) le",
|
"born": "Né(e) le",
|
||||||
"appearances": "Apparitions",
|
"appearances": "Apparences",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Jellyseer ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
"jellyseer_does_not_meet_requirements": "Jellyseer ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||||
"jellyseerr_test_failed": "Échec du test de Jellyseerr",
|
"jellyseerr_test_failed": "Échec du test de Jellyseerr",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Orientation, OrientationLock } from "expo-screen-orientation";
|
import {
|
||||||
|
Orientation,
|
||||||
|
OrientationLock,
|
||||||
|
} from "@/packages/expo-screen-orientation";
|
||||||
|
|
||||||
function orientationToOrientationLock(
|
function orientationToOrientationLock(
|
||||||
orientation: Orientation
|
orientation: Orientation
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
|
||||||
export const orientationAtom = atom<number>(
|
export const orientationAtom = atom<number>(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { storage } from "../mmkv";
|
import { storage } from "../mmkv";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -28,16 +28,26 @@ export const ScreenOrientationEnum: Record<
|
|||||||
ScreenOrientation.OrientationLock,
|
ScreenOrientation.OrientationLock,
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
[ScreenOrientation.OrientationLock.DEFAULT]: "home.settings.other.orientations.DEFAULT",
|
[ScreenOrientation.OrientationLock.DEFAULT]:
|
||||||
[ScreenOrientation.OrientationLock.ALL]: "home.settings.other.orientations.ALL",
|
"home.settings.other.orientations.DEFAULT",
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT]: "home.settings.other.orientations.PORTRAIT",
|
[ScreenOrientation.OrientationLock.ALL]:
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "home.settings.other.orientations.PORTRAIT_UP",
|
"home.settings.other.orientations.ALL",
|
||||||
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "home.settings.other.orientations.PORTRAIT_DOWN",
|
[ScreenOrientation.OrientationLock.PORTRAIT]:
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE]: "home.settings.other.orientations.LANDSCAPE",
|
"home.settings.other.orientations.PORTRAIT",
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "home.settings.other.orientations.LANDSCAPE_LEFT",
|
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
|
||||||
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "home.settings.other.orientations.LANDSCAPE_RIGHT",
|
"home.settings.other.orientations.PORTRAIT_UP",
|
||||||
[ScreenOrientation.OrientationLock.OTHER]: "home.settings.other.orientations.OTHER",
|
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]:
|
||||||
[ScreenOrientation.OrientationLock.UNKNOWN]: "home.settings.other.orientations.UNKNOWN",
|
"home.settings.other.orientations.PORTRAIT_DOWN",
|
||||||
|
[ScreenOrientation.OrientationLock.LANDSCAPE]:
|
||||||
|
"home.settings.other.orientations.LANDSCAPE",
|
||||||
|
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
|
||||||
|
"home.settings.other.orientations.LANDSCAPE_LEFT",
|
||||||
|
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
|
||||||
|
"home.settings.other.orientations.LANDSCAPE_RIGHT",
|
||||||
|
[ScreenOrientation.OrientationLock.OTHER]:
|
||||||
|
"home.settings.other.orientations.OTHER",
|
||||||
|
[ScreenOrientation.OrientationLock.UNKNOWN]:
|
||||||
|
"home.settings.other.orientations.UNKNOWN",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadOptions: DownloadOption[] = [
|
export const DownloadOptions: DownloadOption[] = [
|
||||||
@@ -147,53 +157,53 @@ export type StreamyfinPluginConfig = {
|
|||||||
settings: PluginLockableSettings;
|
settings: PluginLockableSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Settings => {
|
const defaultValues: Settings = {
|
||||||
const defaultValues: Settings = {
|
home: null,
|
||||||
home: null,
|
autoRotate: true,
|
||||||
autoRotate: true,
|
forceLandscapeInVideoPlayer: false,
|
||||||
forceLandscapeInVideoPlayer: false,
|
deviceProfile: "Expo",
|
||||||
deviceProfile: "Expo",
|
mediaListCollectionIds: [],
|
||||||
mediaListCollectionIds: [],
|
preferedLanguage: undefined,
|
||||||
preferedLanguage: undefined,
|
searchEngine: "Jellyfin",
|
||||||
searchEngine: "Jellyfin",
|
marlinServerUrl: "",
|
||||||
marlinServerUrl: "",
|
openInVLC: false,
|
||||||
openInVLC: false,
|
downloadQuality: DownloadOptions[0],
|
||||||
downloadQuality: DownloadOptions[0],
|
libraryOptions: {
|
||||||
libraryOptions: {
|
display: "list",
|
||||||
display: "list",
|
cardStyle: "detailed",
|
||||||
cardStyle: "detailed",
|
imageStyle: "cover",
|
||||||
imageStyle: "cover",
|
showTitles: true,
|
||||||
showTitles: true,
|
showStats: true,
|
||||||
showStats: true,
|
},
|
||||||
},
|
defaultAudioLanguage: null,
|
||||||
defaultAudioLanguage: null,
|
playDefaultAudioTrack: true,
|
||||||
playDefaultAudioTrack: true,
|
rememberAudioSelections: true,
|
||||||
rememberAudioSelections: true,
|
defaultSubtitleLanguage: null,
|
||||||
defaultSubtitleLanguage: null,
|
subtitleMode: SubtitlePlaybackMode.Default,
|
||||||
subtitleMode: SubtitlePlaybackMode.Default,
|
rememberSubtitleSelections: true,
|
||||||
rememberSubtitleSelections: true,
|
showHomeTitles: true,
|
||||||
showHomeTitles: true,
|
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
forwardSkipTime: 30,
|
||||||
forwardSkipTime: 30,
|
rewindSkipTime: 10,
|
||||||
rewindSkipTime: 10,
|
optimizedVersionsServerUrl: null,
|
||||||
optimizedVersionsServerUrl: null,
|
downloadMethod: DownloadMethod.Remux,
|
||||||
downloadMethod: DownloadMethod.Remux,
|
autoDownload: false,
|
||||||
autoDownload: false,
|
showCustomMenuLinks: false,
|
||||||
showCustomMenuLinks: false,
|
disableHapticFeedback: false,
|
||||||
disableHapticFeedback: false,
|
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
remuxConcurrentLimit: 1,
|
||||||
remuxConcurrentLimit: 1,
|
safeAreaInControlsEnabled: true,
|
||||||
safeAreaInControlsEnabled: true,
|
jellyseerrServerUrl: undefined,
|
||||||
jellyseerrServerUrl: undefined,
|
hiddenLibraries: [],
|
||||||
hiddenLibraries: [],
|
};
|
||||||
};
|
|
||||||
|
|
||||||
|
const loadSettings = (): Partial<Settings> => {
|
||||||
try {
|
try {
|
||||||
const jsonValue = storage.getString("settings");
|
const jsonValue = storage.getString("settings");
|
||||||
const loadedValues: Partial<Settings> =
|
const loadedValues: Partial<Settings> =
|
||||||
jsonValue != null ? JSON.parse(jsonValue) : {};
|
jsonValue != null ? JSON.parse(jsonValue) : {};
|
||||||
|
|
||||||
return { ...defaultValues, ...loadedValues };
|
return loadedValues;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load settings:", error);
|
console.error("Failed to load settings:", error);
|
||||||
return defaultValues;
|
return defaultValues;
|
||||||
@@ -212,7 +222,7 @@ const saveSettings = (settings: Settings) => {
|
|||||||
storage.set("settings", jsonValue);
|
storage.set("settings", jsonValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsAtom = atom<Settings | null>(null);
|
export const settingsAtom = atom<Partial<Settings> | null>(null);
|
||||||
export const pluginSettingsAtom = atom(
|
export const pluginSettingsAtom = atom(
|
||||||
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS)
|
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS)
|
||||||
);
|
);
|
||||||
@@ -241,19 +251,18 @@ export const useSettings = () => {
|
|||||||
if (!api) return;
|
if (!api) return;
|
||||||
const settings = await api.getStreamyfinPluginConfig().then(
|
const settings = await api.getStreamyfinPluginConfig().then(
|
||||||
({ data }) => {
|
({ data }) => {
|
||||||
writeInfoLog(`Got remote settings`);
|
writeInfoLog(`Got remote settings: ${data?.settings}`);
|
||||||
return data?.settings;
|
return data?.settings;
|
||||||
},
|
},
|
||||||
(err) => undefined
|
(err) => undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
setPluginSettings(settings);
|
setPluginSettings(settings);
|
||||||
return settings;
|
return settings;
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
const updateSettings = (update: Partial<Settings>) => {
|
const updateSettings = (update: Partial<Settings>) => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
const newSettings = { ...settings, ...update };
|
const newSettings = { ..._settings, ...update };
|
||||||
|
|
||||||
setSettings(newSettings);
|
setSettings(newSettings);
|
||||||
saveSettings(newSettings);
|
saveSettings(newSettings);
|
||||||
@@ -262,7 +271,7 @@ export const useSettings = () => {
|
|||||||
|
|
||||||
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
||||||
// If admin sets locked to false but provides a value,
|
// If admin sets locked to false but provides a value,
|
||||||
// use user settings first and fallback on admin setting if required.
|
// use user settings first and fallback on admin setting if required.
|
||||||
const settings: Settings = useMemo(() => {
|
const settings: Settings = useMemo(() => {
|
||||||
let unlockedPluginDefaults = {} as Settings;
|
let unlockedPluginDefaults = {} as Settings;
|
||||||
const overrideSettings = Object.entries(pluginSettings || {}).reduce(
|
const overrideSettings = Object.entries(pluginSettings || {}).reduce(
|
||||||
@@ -291,12 +300,8 @@ export const useSettings = () => {
|
|||||||
{} as Settings
|
{} as Settings
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update settings with plugin defined defaults
|
|
||||||
if (Object.keys(unlockedPluginDefaults).length > 0) {
|
|
||||||
updateSettings(unlockedPluginDefaults);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...defaultValues,
|
||||||
..._settings,
|
..._settings,
|
||||||
...overrideSettings,
|
...overrideSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import * as BackgroundFetch from "expo-background-fetch";
|
import { Platform } from "react-native";
|
||||||
|
const BackgroundFetch = !Platform.isTV
|
||||||
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
|
|
||||||
export const BACKGROUND_FETCH_TASK = "background-fetch";
|
export const BACKGROUND_FETCH_TASK = "background-fetch";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user