Compare commits

..

68 Commits

Author SHA1 Message Date
herrrta
f28f1d8736 Fix android discover page crash 2025-02-11 10:16:36 -05:00
lostb1t
e0f03ccb93 feat: Allow plugin override defaults (#508) 2025-02-10 17:38:01 +01:00
lostb1t
34d1dbb20e Update README.md 2025-02-10 15:39:14 +01:00
Simon Eklundh
e3e2db659d fix: download player (#506) 2025-02-09 13:40:45 +01:00
Fredrik Burmester
528b4ad7ac fix: orientation in video player and app i general 2025-02-09 11:45:32 +01:00
lostb1t
d29501386b chore: expo 52 (#502)
Co-authored-by: herrrta <73949927+herrrta@users.noreply.github.com>
2025-02-09 10:46:05 +01:00
Simon Eklundh
6688469b6c fix: fixes non-optimized downloads (#500) 2025-02-09 10:43:42 +01:00
lostb1t
ae9c30aa6d fix: fix home and header nav not showing (#499) 2025-02-08 17:48:05 +01:00
Fredrik Burmester
364d2e8a51 fix: typescript errors 2025-02-08 10:51:52 +01:00
herrrta
6cc90b46b3 TV: fix navigation on login (#494) 2025-02-07 21:57:13 -05:00
sarendsen
33adea2819 fix more import for tv 2025-02-07 14:22:54 +01:00
Simon Eklundh
9f41861dcf fix: download provider import usage so we can play again (#491) 2025-02-06 23:12:44 +01:00
lostb1t
2b2d23e574 Update README.md 2025-02-06 17:53:01 +01:00
lostb1t
f6e2bcb120 Update README.md 2025-02-06 17:52:02 +01:00
lostb1t
314cd62bee Update README.md 2025-02-06 17:48:25 +01:00
lostb1t
41e7123d1c Update README.md 2025-02-06 17:48:03 +01:00
lostb1t
2af42b39f5 Update README.md 2025-02-06 17:44:31 +01:00
lostb1t
0a06b336c8 Update network_security_config.xml 2025-02-06 12:37:17 +01:00
lostb1t
028c9159f3 Update eas.json 2025-02-06 09:38:38 +01:00
sarendsen
dee4fa07e3 refactor: playbutton for tv 2025-02-05 15:07:11 +01:00
lostb1t
2764f1736a Update eas.json 2025-02-05 13:58:30 +01:00
Fredrik Burmester
d3d1a7bcde Merge pull request #374 from streamyfin/feature/bigscreen
feat: Initial support for tvOs/AndroidTV
2025-02-05 13:41:19 +01:00
sarendsen
7fcd598fa1 wip 2025-02-05 10:04:50 +01:00
sarendsen
0fc1506b11 merge develop 2025-02-05 09:44:03 +01:00
Adrián
e0aa7ea0df fix: Change phone_usage key to device_usage in Spanish translations (#479) 2025-02-04 15:23:41 +01:00
Mustafa
25f77645f8 fix: phone_usage to device_usage due PR #456 for DE language (#478) 2025-02-02 13:05:58 +01:00
Gauvain
1c81091e8b fix(i18n): fix french translation and wrong keys (#456) 2025-02-02 09:20:08 +01:00
Mustafa
94502b558d feat: Add German Translation DE (#477) 2025-02-02 09:18:15 +01:00
Adrián
a7d7d00eb3 feat: Translate app to Spanish (#457) 2025-02-02 09:17:54 +01:00
Fredrik Burmester
3b5e07c1d2 chore 2025-02-01 10:14:09 +01:00
Fredrik Burmester
db10369fb5 chore 2025-02-01 09:29:05 +01:00
Fredrik Burmester
32da5918c7 chore 2025-01-31 15:57:03 +01:00
Fredrik Burmester
dc542021b5 chore 2025-01-31 15:47:03 +01:00
Fredrik Burmester
bfad157a28 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-31 15:36:52 +01:00
Fredrik Burmester
a71a646743 chore 2025-01-31 15:36:49 +01:00
sarendsen
366bc0137e WIP 2025-01-31 13:22:51 +01:00
Tom Heidenreich
3eb60840e6 fix: Rendered more hooks than during the previous render in NextEpisodeCountDownButton (#475) 2025-01-31 10:14:59 +01:00
sarendsen
65c4a1340d WIP 2025-01-30 11:19:36 +01:00
sarendsen
3e90447dd4 WIP 2025-01-30 10:18:07 +01:00
sarendsen
bd0768797e WIP 2025-01-30 09:20:31 +01:00
Max Ward
730ef4616f feat: Mark entire seasons of a show as played (#445) 2025-01-29 10:54:00 +01:00
lostb1t
c4d4475aa9 Create lint-pr.yaml 2025-01-27 14:04:22 +01:00
Fredrik Burmester
d1eb40f2a9 chore 2025-01-27 13:26:23 +01:00
Fredrik Burmester
77518d774e chore 2025-01-27 13:00:40 +01:00
Fredrik Burmester
a6fb7b956d chore 2025-01-27 13:00:16 +01:00
Tom Heidenreich
034ff3f478 Feat/Show Splashcreen until UI loaded (#437) 2025-01-27 10:28:53 +01:00
Max Ward
98ca4e7a6d Fix mark as played sheet logic being reversed (#443) 2025-01-27 08:27:28 +01:00
herrrta
461a276a20 Merge pull request #461 from streamyfin/fix/460
fix: Requesting some seasons not working [Jellyseerr]
2025-01-25 15:06:08 -05:00
herrrta
3975473da9 fix: Requesting some seasons not working [Jellyseerr] 2025-01-25 15:05:48 -05:00
lostb1t
d34b86297a Update ScrollingCollectionList.tsx 2025-01-24 09:06:30 +01:00
sarendsen
c4a83e283f feat: hide sections when empty by default 2025-01-24 08:59:41 +01:00
sarendsen
dac471f0a6 feat: hide sections when empty by default 2025-01-24 08:55:02 +01:00
sarendsen
3cd8e41000 wip 2025-01-08 15:25:06 +01:00
sarendsen
dd08826931 wip 2025-01-07 12:03:35 +01:00
sarendsen
b681025389 wip 2025-01-07 12:01:55 +01:00
sarendsen
65549428bf wip 2025-01-07 10:45:25 +01:00
sarendsen
cda3b64a2b wip 2025-01-07 10:08:07 +01:00
sarendsen
373d4ca3b1 wip 2025-01-06 15:10:59 +01:00
sarendsen
8bc360d554 wip 2025-01-06 15:04:07 +01:00
sarendsen
3fae21d559 wip 2025-01-06 14:45:42 +01:00
sarendsen
74ce9d7eea wip 2025-01-06 14:28:24 +01:00
sarendsen
5055a700c9 wip 2025-01-06 13:59:56 +01:00
sarendsen
ab33693dd9 wip 2025-01-06 13:25:49 +01:00
Fredrik Burmester
6a4621c377 Merge branch 'develop' into feature/bigscreen 2025-01-05 13:43:23 +01:00
sarendsen
2fb19f601b remove reload 2025-01-05 10:54:14 +01:00
sarendsen
a602c35a8f refactor: Add support for tvos 2025-01-05 10:43:10 +01:00
retardgerman
46ac4a2cc7 fix: auto add feature requests to roadmap 2025-01-05 10:42:08 +01:00
retardgerman
962f65874e fix: removed assignees and modified link to roadmap 2025-01-05 10:42:08 +01:00
84 changed files with 12542 additions and 1220 deletions

41
.github/workflows/lint-pr.yaml vendored Normal file
View 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
View File

@@ -26,6 +26,10 @@ package-lock.json
/ios /ios
/android /android
/iostv
/iosmobile
/androidmobile
/androidtv
modules/player/android modules/player/android

View File

@@ -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
View 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,
};
};

View File

@@ -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
} }
} }

View File

@@ -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" />}

View File

@@ -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>
), ),
}} }}

View File

@@ -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") {

View File

@@ -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>

View File

@@ -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

View File

@@ -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";

View File

@@ -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) => (

View File

@@ -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";

View File

@@ -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

View File

@@ -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 />

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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>
); );
} }

BIN
bun.lockb

Binary file not shown.

View File

@@ -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}

View File

@@ -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}

View File

@@ -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

View File

@@ -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>
); );
}; }

View File

View File

@@ -0,0 +1 @@
export * from "zeego/context-menu";

View File

View 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" && (

View File

@@ -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>

View File

@@ -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);

View 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>
);
};

View File

@@ -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>
); );

View File

@@ -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

View File

@@ -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;

View File

@@ -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}
/> />
); )
} }

View File

@@ -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>
</> </>

View File

@@ -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);
} }
} }
); );

View File

@@ -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: () => {

View File

@@ -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()})`;

View File

@@ -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],

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
); );
}; };

View File

@@ -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

View File

@@ -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>
); );
}; }

View File

@@ -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);
}; };

View File

@@ -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}

View File

@@ -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>
</> </>

View File

@@ -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>

View File

@@ -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

View File

@@ -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);

View File

@@ -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";

View File

@@ -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"

View File

@@ -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";

View File

@@ -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";

View File

@@ -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": {

View File

@@ -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
), ),

View File

@@ -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);
}); });
} }

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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(() => {

View File

@@ -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
} }

View File

@@ -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
View 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;

View File

@@ -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 {

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -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"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from "expo-screen-orientation";

View 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,
}

View File

@@ -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>

View 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;
};

View File

@@ -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"
)
);
}); });
}; };

View File

@@ -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>
); );
}; };

View 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;
}

View 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
View 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"
}
}

View File

@@ -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
View 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"
}
}

View File

@@ -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 longlet Bibliothèque et les sections de la page daccueil.",
"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",

View File

@@ -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

View File

@@ -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>(

View File

@@ -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,
}; };

View File

@@ -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";

9412
yarn.lock Normal file

File diff suppressed because it is too large Load Diff