Compare commits

..

88 Commits

Author SHA1 Message Date
Alex Kim
6494049b66 WIP 2025-02-16 06:39:02 +11:00
Alex Kim
adb20f4b10 Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:55:40 +11:00
Alex Kim
7dc8132e7a Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:53:42 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
Alex Kim
9fb04518b0 WIP 2025-02-16 04:17:40 +11:00
Fredrik Burmester
33a2be24f4 fix: hidden be default ios 2025-02-15 12:00:58 +01:00
Fredrik Burmester
e8b0d52515 feat: change to native searchbar on android 2025-02-15 11:59:17 +01:00
Fredrik Burmester
9faa0de2d6 chore: bump version 2025-02-15 11:48:54 +01:00
Fredrik Burmester
221155d002 fix: deps 2025-02-15 11:34:50 +01:00
Fredrik Burmester
46be3c9465 fix: remove all mentions of transcoding player and the route
only use vlc player
2025-02-14 13:46:01 +01:00
Fredrik Burmester
4a37e17324 chore 2025-02-14 13:28:06 +01:00
Fredrik Burmester
52b2a3418e fix: wrong deps 2025-02-13 10:35:58 +01:00
Fredrik Burmester
2753b243e5 chore: remove yarn lock file 2025-02-13 10:33:56 +01:00
Fredrik Burmester
f22b356b7c feat: turkish translations 2025-02-13 10:33:41 +01:00
Fredrik Burmester
d8ba5af8d9 chore: remove old patch 2025-02-13 10:33:34 +01:00
herrrta
505ef39ee7 ios VLCKit 4.0 & All platform PiP support 2025-02-12 23:21:24 -05:00
Théo FORTIN
e71d5cc176 feat: Add default quality setting (#509) 2025-02-12 08:32:26 +01:00
Fredrik Burmester
74e57bbd88 fix: add contributor avatars to readme (#512) 2025-02-12 08:31:55 +01:00
Fredrik Burmester
76eaeb9820 chore 2025-02-12 08:31:38 +01:00
Fredrik Burmester
9a70f98dd5 chore 2025-02-12 08:25:36 +01:00
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
107 changed files with 7514 additions and 3039 deletions

View File

@@ -43,6 +43,8 @@ body:
label: Version label: Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running?
options: options:
- 0.26.1
- 0.26.0
- 0.25.0 - 0.25.0
- 0.24.0 - 0.24.0
- 0.23.0 - 0.23.0

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
@@ -116,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
## ✨ Acknowledgements ## ✨ Acknowledgements
I'd like to thank the following people and projects for their contributions to Streamyfin: ### Core Developers
Thanks to the following contributors for their significant contributions:
<table>
<tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
</tr>
</table>
And all other developers who have contributed to Streamyfin, thank you for your contributions.
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.

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

@@ -2,16 +2,11 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.25.0", "version": "0.26.1",
"orientation": "default", "orientation": "default",
"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": {
@@ -36,7 +31,7 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 50, "versionCode": 53,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png" "foregroundImage": "./assets/images/adaptive_icon.png"
}, },
@@ -48,15 +43,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 +68,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 +100,25 @@
"expo-asset", "expo-asset",
[ [
"react-native-edge-to-edge", "react-native-edge-to-edge",
{ "android": { "parentTheme": "Material3" } } {
"android": {
"parentTheme": "Material3"
}
}
], ],
["react-native-bottom-tabs"], ["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withGoogleCastActivity.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.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 +137,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,7 +24,9 @@ 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 && (
<>
<Chromecast.Chromecast />
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
router.push("/(auth)/settings"); router.push("/(auth)/settings");
@@ -33,6 +34,8 @@ export default function IndexLayout() {
> >
<Feather name="settings" color={"white"} size={22} /> <Feather name="settings" color={"white"} size={22} />
</TouchableOpacity> </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,11 +78,12 @@ 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();
if (!Platform.isTV) {
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => { useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0; const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({ navigation.setOptions({
@@ -98,6 +104,13 @@ export default function index() {
}); });
}, [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);
const state = await NetInfo.fetch(); const state = await NetInfo.fetch();
@@ -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]
@@ -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({
@@ -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

@@ -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,7 +71,8 @@ export default function settings() {
</MediaProvider> </MediaProvider>
<OtherSettings /> <OtherSettings />
<DownloadSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings /> <PluginSettings />

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,10 +29,16 @@ 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";
@@ -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) {
@@ -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,8 +27,9 @@ 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 &&
!Platform.isTV && (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Ionicons <Ionicons
@@ -46,7 +47,9 @@ export default function IndexLayout() {
side={"bottom"} side={"bottom"}
sideOffset={10} sideOffset={10}
> >
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label> <DropdownMenu.Label>
{t("library.options.display")}
</DropdownMenu.Label>
<DropdownMenu.Group key="display-group"> <DropdownMenu.Group key="display-group">
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger"> <DropdownMenu.SubTrigger key="image-style-trigger">
@@ -108,7 +111,9 @@ export default function IndexLayout() {
> >
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key="poster-option" key="poster-option"
value={settings.libraryOptions.imageStyle === "poster"} value={
settings.libraryOptions.imageStyle === "poster"
}
onValueChange={() => onValueChange={() =>
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
@@ -148,7 +153,7 @@ export default function IndexLayout() {
disabled={settings.libraryOptions.imageStyle === "poster"} disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option" key="show-titles-option"
value={settings.libraryOptions.showTitles} value={settings.libraryOptions.showTitles}
onValueChange={(newValue) => { onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster") if (settings.libraryOptions.imageStyle === "poster")
return; return;
updateSettings({ updateSettings({
@@ -167,7 +172,7 @@ export default function IndexLayout() {
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key="show-stats-option" key="show-stats-option"
value={settings.libraryOptions.showStats} value={settings.libraryOptions.showStats}
onValueChange={(newValue) => { onValueChange={(newValue: string) => {
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,

View File

@@ -38,9 +38,18 @@ export default function SearchLayout() {
}} }}
/> />
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} /> <Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} /> <Stack.Screen
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} /> name="jellyseerr/person/[personId]"
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} /> options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/company/[companyId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/genre/[genreId]"
options={commonScreenOptions}
/>
</Stack> </Stack>
); );
} }

View File

@@ -50,7 +50,7 @@ export default function search() {
const { t } = useTranslation(); const { t } = useTranslation();
const { q, prev } = params as { q: string; prev: Href<string> }; const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
@@ -122,7 +122,6 @@ export default function search() {
const navigation = useNavigation(); const navigation = useNavigation();
useLayoutEffect(() => { useLayoutEffect(() => {
if (Platform.OS === "ios")
navigation.setOptions({ navigation.setOptions({
headerSearchBarOptions: { headerSearchBarOptions: {
placeholder: t("search.search"), placeholder: t("search.search"),
@@ -211,18 +210,6 @@ export default function search() {
}} }}
> >
<View className="flex flex-col"> <View className="flex flex-col">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder={t("search.search_here")}
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
)}
{jellyseerrApi && ( {jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2"> <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}> <TouchableOpacity onPress={() => setSearchType("Library")}>

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 />
@@ -16,15 +36,6 @@ export default function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack> </Stack>
</> </>
); );

View File

@@ -3,32 +3,31 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player"; import { VlcPlayerView } from "@/modules/vlc-player";
import { import {
PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
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 { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { import {
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router"; import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, {
useCallback, useCallback,
@@ -37,25 +36,20 @@ import React, {
useState, useState,
useEffect, useEffect,
} from "react"; } from "react";
import { import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
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);
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
@@ -63,12 +57,16 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
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 +107,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;
} }
@@ -125,25 +123,37 @@ export default function page() {
staleTime: 0, staleTime: 0,
}); });
const { const [stream, setStream] = useState<{
data: stream, mediaSource: MediaSourceInfo;
isLoading: isLoadingStreamUrl, url: string;
isError: isErrorStreamUrl, sessionId: string | undefined;
} = useQuery({ } | null>(null);
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], const [isLoadingStream, setIsLoadingStream] = useState(true);
queryFn: async () => { const [isErrorStream, setIsErrorStream] = useState(false);
if (offline) {
const data = await getDownloadedItem(itemId); useEffect(() => {
if (!data?.mediaSource) return null; const fetchStream = async () => {
setIsLoadingStream(true);
setIsErrorStream(false);
try {
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) {
setStream(null);
return;
}
const url = await getDownloadedFileUrl(data.item.Id!); const url = await getDownloadedFileUrl(data.item.Id!);
if (item) if (item) {
return { setStream({
mediaSource: data.mediaSource, mediaSource: data.mediaSource as MediaSourceInfo,
url, url,
sessionId: undefined, sessionId: undefined,
}; });
return;
}
} }
const res = await getStreamUrl({ const res = await getStreamUrl({
@@ -158,24 +168,35 @@ export default function page() {
deviceProfile: native, deviceProfile: native,
}); });
if (!res) return null; if (!res) {
setStream(null);
return;
}
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) { if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url")); Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null; setStream(null);
return;
} }
return { setStream({
mediaSource, mediaSource,
sessionId, sessionId,
url, url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
}); });
} catch (error) {
console.error("Error fetching stream:", error);
setIsErrorStream(true);
setStream(null);
} finally {
setIsLoadingStream(false);
}
};
fetchStream();
}, [itemId, mediaSourceId]);
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
if (!api) return; if (!api) return;
@@ -183,6 +204,9 @@ export default function page() {
lightHapticFeedback(); lightHapticFeedback();
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
} else {
videoRef.current?.play();
}
if (!offline && stream) { if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({ await getPlaystateApi(api).onPlaybackProgress({
@@ -190,31 +214,12 @@ export default function page() {
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value), positionTicks: msToTicks(progress.get()),
isPaused: true, isPaused: !isPlaying,
playMethod: stream.url?.includes("m3u8") playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
} }
} else {
videoRef.current?.play();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
}
}, [ }, [
isPlaying, isPlaying,
api, api,
@@ -225,13 +230,13 @@ export default function page() {
subtitleIndex, subtitleIndex,
mediaSourceId, mediaSourceId,
offline, offline,
progress.value, progress,
]); ]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.value); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
@@ -249,25 +254,9 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
if (!stream) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
}, [api, item, mediaSourceId, stream]);
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return; if (isSeeking.get() || isPlaybackStopped) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
@@ -275,7 +264,7 @@ export default function page() {
setIsBuffering(false); setIsBuffering(false);
} }
progress.value = currentTime; progress.set(currentTime);
if (offline) return; if (offline) return;
@@ -294,12 +283,9 @@ export default function page() {
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
}, },
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex] [item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
); );
useOrientation();
useOrientationSettings();
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
togglePlay: togglePlay, togglePlay: togglePlay,
@@ -307,6 +293,11 @@ export default function page() {
offline, offline,
}); });
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
@@ -336,25 +327,13 @@ export default function page() {
: 0; : 0;
}, [item]); }, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState); const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => { useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => { const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
// Handle app going to the background // Handle app going to the background
if (videoRef.current && videoRef.current.pause) { if (nextAppState.match(/inactive|background/)) {
videoRef.current.pause(); _setShowControls(false);
}
} }
setAppState(nextAppState); setAppState(nextAppState);
}; };
@@ -369,69 +348,71 @@ export default function page() {
// Cleanup the event listener when the component is unmounted // Cleanup the event listener when the component is unmounted
subscription.remove(); subscription.remove();
}; };
}, [appState]); }, [appState, isPipStarted, isPlaying]);
// Preselection of audio and subtitle tracks. // Preselection of audio and subtitle tracks.
if (!settings) return null; if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const allAudio = const allAudio =
stream?.mediaSource.MediaStreams?.filter( stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio" (audio) => audio.Type === "Audio"
) || []; ) || [];
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
// Direct playback CASE const notTranscoding = !stream?.mediaSource.TranscodingUrl;
if (!bitrateValue) { if (
// If Subtitle is embedded we can use the position to select it straight away. chosenSubtitleTrack &&
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) { (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`); ) {
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) { const finalIndex = notTranscoding
// If Subtitle is external we need to pass the URL to the player. ? allSubs.indexOf(chosenSubtitleTrack)
externalTrack = { : textSubs.indexOf(chosenSubtitleTrack);
name: chosenSubtitleTrack.DisplayTitle || "", initOptions.push(`--sub-track=${finalIndex}`);
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
};
} }
if (chosenAudioTrack) if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
} }
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || isLoadingStreamUrl || !stream) if (!item || isLoadingItem || !stream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader /> <Loader />
</View> </View>
); );
if (isErrorItem || isErrorStreamUrl) if (isErrorItem || isErrorStream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text> <Text className="text-white">{t("player.error")}</Text>
</View> </View>
); );
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
<View <View
@@ -449,17 +430,18 @@ export default function page() {
<VlcPlayerView <VlcPlayerView
ref={videoRef} ref={videoRef}
source={{ source={{
uri: stream.url, uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: true,
startPosition, startPosition,
externalTrack, externalSubtitles,
initOptions, initOptions,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}} onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
@@ -490,6 +472,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play} play={videoRef.current?.play}
pause={videoRef.current?.pause} pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo} seek={videoRef.current?.seekTo}
@@ -507,22 +490,3 @@ export default function page() {
</View> </View>
); );
} }
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

@@ -1,547 +0,0 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { TrackInfo } from "@/modules/vlc-player";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import transcoding from "@/utils/profiles/transcoding";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrack,
SelectedTrackType,
VideoRef,
} from "react-native-video";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
const Player = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const { t } = useTranslation();
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
}, []);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr
? parseInt(subtitleIndexStr, 10)
: undefined;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: undefined;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!itemId) {
console.warn("No itemId");
return null;
}
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
staleTime: 0,
});
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!item) {
console.warn("No item", itemId, item);
return null;
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: transcoding,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
console.warn("No sessionId or mediaSource or url", url);
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!item,
staleTime: 0,
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: true,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
}
}, [
isPlaying,
api,
item,
videoRef,
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const seek = useCallback(
(seconds: number) => {
videoRef.current?.seek(seconds);
},
[videoRef]
);
const reportPlaybackStopped = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
playSessionId: stream?.sessionId,
});
revalidateProgressCache();
};
const stop = useCallback(() => {
reportPlaybackStopped();
videoRef.current?.pause();
setIsPlaybackStopped(true);
}, [videoRef, reportPlaybackStopped]);
const reportPlaybackStart = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = secondsToTicks(data.currentTime);
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration);
// TODO: Use this when streaming with HLS url, but NOT when direct playing
// TODO: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
if (!item?.Id || data.currentTime === 0) {
return;
}
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
isSeeking,
stream,
mediaSourceId,
audioIndex,
subtitleIndex,
]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline: false,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
SelectedTrack | undefined
>();
const [embededTextTracks, setEmbededTextTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
useEffect(() => {
if (selectedTextTrack === undefined) {
const subtitleHelper = new SubtitleHelper(
stream?.mediaSource.MediaStreams ?? []
);
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
subtitleIndex!
);
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: embeddedTrackIndex,
});
}
}, [embededTextTracks]);
const getAudioTracks = (): TrackInfo[] => {
return audioTracks.map((t) => ({
name: t.name,
index: t.index,
}));
};
const getSubtitleTracks = (): TrackInfo[] => {
return embededTextTracks.map((t) => ({
name: t.title ?? "",
index: t.index,
language: t.language,
}));
};
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
{videoSource ? (
<>
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
</>
) : (
<Text>{t("player.no_video_source")}</Text>
)}
</View>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
stop={stop}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) => {
if (i === -1) {
setSelectedTextTrack({
type: SelectedTrackType.DISABLED,
value: undefined,
});
return;
}
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
/>
)}
</View>
);
};
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster, url]);
return videoSource;
}
export default Player;

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,36 +23,32 @@ 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,
@@ -55,27 +56,32 @@ Notifications.setNotificationHandler({
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(
(response: { notification: any }) => {
if (!isMounted || !response?.notification) { if (!isMounted || !response?.notification) {
return; 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,6 +93,7 @@ function useNotificationObserver() {
}, []); }, []);
} }
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
@@ -120,14 +127,14 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
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;
} }
download({ BackGroundDownloader.download({
id: job.id, id: job.id,
url: downloadUrl, url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`, destination: `${baseDirectory}${job.item.Id}.mp4`,
@@ -141,7 +148,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
.done(() => { .done(() => {
console.log("TaskManager ~ Download completed: ", job.id); console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item); saveDownloadedItemInfo(job.item);
completeHandler(job.id); BackGroundDownloader.completeHandler(job.id);
cancelJobById({ cancelJobById({
authHeader: token, authHeader: token,
id: job.id, id: job.id,
@@ -158,9 +165,9 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
trigger: null, trigger: null,
}); });
}) })
.error((error) => { .error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error); console.log("TaskManager ~ Download error: ", job.id, error);
completeHandler(job.id); BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({ Notifications.scheduleNotificationAsync({
content: { content: {
title: job.item.Name, title: job.item.Name,
@@ -180,6 +187,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
// 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 (
<SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider> <JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Layout /> <Layout />
</I18nextProvider> </I18nextProvider>
</ActionSheetProvider>
</JotaiProvider> </JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
); );
} }
@@ -251,9 +251,16 @@ 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);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
);
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
useKeepAwake(); useKeepAwake();
useNotificationObserver(); useNotificationObserver();
@@ -264,65 +271,50 @@ function Layout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (settings?.autoRotate === true) // If the user has auto rotate enabled, unlock the orientation
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT); if (settings.autoRotate === true) {
else ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync( ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP ScreenOrientation.OrientationLock.PORTRAIT_UP
); );
}
}, [settings]); }, [settings]);
useEffect(() => { useEffect(() => {
i18n.changeLanguage( const subscription = AppState.addEventListener(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en" "change",
); (nextAppState) => {
}, [settings?.preferedLanguage, i18n]);
const appState = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if ( if (
appState.current.match(/inactive|background/) && appState.current.match(/inactive|background/) &&
nextAppState === "active" nextAppState === "active"
) { ) {
checkForExistingDownloads(); BackGroundDownloader.checkForExistingDownloads();
} }
}); }
);
checkForExistingDownloads(); BackGroundDownloader.checkForExistingDownloads();
return () => { return () => {
subscription.remove(); subscription.remove();
}; };
}, []); }, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(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}>
<ActionSheetProvider>
<JobQueueProvider> <JobQueueProvider>
<JellyfinProvider> <JellyfinProvider>
<PlaySettingsProvider> <PlaySettingsProvider>
@@ -332,7 +324,7 @@ function Layout() {
<BottomSheetModalProvider> <BottomSheetModalProvider>
<SystemBars style="light" hidden={false} /> <SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home"> <Stack>
<Stack.Screen <Stack.Screen
name="(auth)/(tabs)" name="(auth)/(tabs)"
options={{ options={{
@@ -381,9 +373,7 @@ function Layout() {
</PlaySettingsProvider> </PlaySettingsProvider>
</JellyfinProvider> </JellyfinProvider>
</JobQueueProvider> </JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider> </QueryClientProvider>
</GestureHandlerRootView>
); );
} }

2934
bun.lock Normal file

File diff suppressed because it is too large Load Diff

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

@@ -66,7 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1); const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0); useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({ const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max", key: "Max",
value: undefined, value: undefined,
}); });
@@ -194,10 +194,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
for (const item of items) { for (const item of items) {
if (itemsNotDownloaded.length > 1) { if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings( const defaults = getDefaultPlaySettings(item, settings!);
item, mediaSource = defaults.mediaSource;
settings! audioIndex = defaults.audioIndex;
)); subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
} }
const res = await getStreamUrl({ const res = await getStreamUrl({

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";
@@ -15,7 +16,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { import {
@@ -24,12 +24,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,16 +81,21 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultMediaSource, defaultMediaSource,
]); ]);
if (!Platform.isTV) {
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && ( item && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} /> <Chromecast.Chromecast
background="blur"
width={22}
height={22}
/>
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" /> <DownloadSingleItem item={item} size="large" />
<PlayedStatus item={item} /> <PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} type="item" /> <AddToFavorites item={item} type="item" />
</View> </View>
)} )}
@@ -98,6 +103,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
), ),
}); });
}, [item]); }, [item]);
}
useEffect(() => { useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
@@ -111,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => { const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo); return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]); }, [loadingLogo, logoUrl]);
const [isTranscoding, setIsTranscoding] = useState(false);
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
useState<number | undefined>(selectedOptions?.subtitleIndex);
useEffect(() => {
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
if (isTranscoding) {
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
const subHelper = new SubtitleHelper(
selectedOptions?.mediaSource?.MediaStreams ?? []
);
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
selectedOptions?.subtitleIndex
);
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: newSubtitleIndex ?? -1,
}));
}
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: previouslyChosenSubtitleIndex,
}));
}
setIsTranscoding(isTranscoding);
}, [selectedOptions?.bitrate]);
if (!selectedOptions) return null; if (!selectedOptions) return null;
return ( return (
@@ -189,9 +164,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"
@@ -231,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
@@ -247,11 +222,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";
@@ -70,11 +73,7 @@ export const PlayButton: React.FC<Props> = ({
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
}, },
[router] [router]
); );
@@ -114,6 +113,7 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
if (!Platform.isTV) {
await CastContext.getPlayServicesState().then(async (state) => { await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
@@ -207,6 +207,7 @@ export const PlayButton: React.FC<Props> = ({
}); });
} }
}); });
}
break; break;
case 1: case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString, selectedOptions.bitrate?.value);

View File

@@ -0,0 +1,247 @@
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) => {
router.push(`/player/direct-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 = () => {
items.forEach((item) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["item", item.Id], 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,41 +2,33 @@ 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
onChange: (value: number) => void; onChange: (value: number) => void;
selected?: number | undefined; selected?: number | undefined;
isTranscoding?: boolean;
} }
export const SubtitleTrackSelector: React.FC<Props> = ({ export const SubtitleTrackSelector: React.FC<Props> = ({
source, source,
onChange, onChange,
selected, selected,
isTranscoding,
...props ...props
}) => { }) => {
if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo( const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected), () => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected] [subtitleStreams, selected]
); );
if (subtitleStreams.length === 0) return null; if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -51,7 +43,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,19 +1,24 @@
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>({
@@ -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={
selected?.some((s) => keyExtractor(s) == keyExtractor(item))
? "on"
: "off"
}
key={keyExtractor(item)} key={keyExtractor(item)}
onValueChange={(next, previous) => onValueChange={(next, previous) =>
setSelected((p) => { setSelected((p) => {
const prev = p || [] const prev = p || [];
if (next == 'on') { if (next == "on") {
return [...prev, item] return [...prev, item];
} }
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))] return [
...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item)
),
];
}) })
} }
> >
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
) ) : (
: (
<DropdownMenu.Item <DropdownMenu.Item
key={keyExtractor(item)} key={keyExtractor(item)}
onSelect={() => setSelected([item])} onSelect={() => setSelected([item])}
> >
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
) )
))} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</DisabledSetting> </DisabledSetting>
) );
}; };
export default Dropdown; export default Dropdown;

View File

@@ -1,10 +1,13 @@
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 <TextInput
ref={inputRef} ref={inputRef}
className="p-4 rounded-xl bg-neutral-900" className="p-4 rounded-xl bg-neutral-900"
@@ -14,5 +17,16 @@ export function Input(props: TextInputProps) {
clearButtonMode="while-editing" clearButtonMode="while-editing"
{...otherProps} {...otherProps}
/> />
); </TouchableOpacity>
) : (
<TextInput
ref={inputRef}
className="p-4 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
{...otherProps}
/>
)
} }

View File

@@ -1,10 +1,13 @@
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import React, { PropsWithChildren, useCallback, useMemo } from "react"; import React, { PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native"; import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu"; import * 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 {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
@@ -26,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}
> >
@@ -75,12 +88,14 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
key="item-1" key="item-1"
onSelect={() => { onSelect={() => {
if (autoApprove) { if (autoApprove) {
request() request();
} }
}} }}
shouldDismissMenuOnSelect shouldDismissMenuOnSelect
> >
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle> <ContextMenu.ItemTitle key="item-1-title">
Request
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon <ContextMenu.ItemIcon
ios={{ ios={{
name: "arrow.down.to.line", name: "arrow.down.to.line",

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,6 +147,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
}} }}
/> />
{episodes?.length || 0 > 0 ? ( {episodes?.length || 0 > 0 ? (
<View className="flex flex-row items-center space-x-2">
<DownloadItems <DownloadItems
title={t("item_card.download.download_season")} title={t("item_card.download.download_season")}
className="ml-2" className="ml-2"
@@ -156,6 +159,8 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
<Ionicons name="download" size={20} color="#9333ea" /> <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,9 +18,7 @@ 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>

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";
@@ -12,6 +12,7 @@ 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,14 +1,18 @@
import { Platform } from "react-native";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync, registerBackgroundFetchAsync,
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 +33,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);
}; };
@@ -158,6 +164,32 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")} title={t("home.settings.other.hide_libraries")}
showArrow showArrow
/> />
<ListItem
title="Default quality"
disabled={pluginSettings?.defaultBitrate?.locked}
>
<Dropdown
data={BITRATES}
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
selected={settings.defaultBitrate}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.defaultBitrate?.key}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/>
</ListItem>
<ListItem <ListItem
title={t("home.settings.other.disable_haptic_feedback")} title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}

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";
@@ -15,6 +15,7 @@ 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,9 +80,11 @@ 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:
defaultSubtitleLanguage.DisplayName ===
t("home.settings.subtitles.none")
? null ? null
: defaultSubtitleLanguage : defaultSubtitleLanguage,
}) })
} }
/> />
@@ -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>

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

@@ -24,18 +24,23 @@ import {
ticksToMs, ticksToMs,
ticksToSeconds, ticksToSeconds,
} from "@/utils/time"; } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} 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";
import { TouchableOpacity, useWindowDimensions, View } from "react-native"; import {
Platform,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { import {
runOnJS, runOnJS,
@@ -49,8 +54,7 @@ import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext"; import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext"; import { VideoProvider } from "./contexts/VideoContext";
import DropdownViewDirect from "./dropdown/DropdownViewDirect"; import DropdownView from "./dropdown/DropdownView";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList"; import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
@@ -75,6 +79,7 @@ interface Props {
isVideoLoaded?: boolean; isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null; mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void; seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void); play: (() => Promise<void>) | (() => void);
pause: () => void; pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]); getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -91,6 +96,7 @@ const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({ export const Controls: React.FC<Props> = ({
item, item,
seek, seek,
startPictureInPicture,
play, play,
pause, pause,
togglePlay, togglePlay,
@@ -212,13 +218,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
if (!bitrateValue) { stop();
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]); }, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {
@@ -250,13 +253,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
if (!bitrateValue) { stop();
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]); }, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback( const updateTimes = useCallback(
@@ -413,13 +413,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
if (!bitrateValue) { stop();
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
} catch (error) { } catch (error) {
console.error("Error in gotoEpisode:", error); console.error("Error in gotoEpisode:", error);
} }
@@ -499,6 +496,15 @@ export const Controls: React.FC<Props> = ({
); );
}, [trickPlayUrl, trickplayInfo, time]); }, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
stop();
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
};
return ( return (
<ControlProvider <ControlProvider
item={item} item={item}
@@ -542,15 +548,22 @@ export const Controls: React.FC<Props> = ({
setSubtitleTrack={setSubtitleTrack} setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL} setSubtitleURL={setSubtitleURL}
> >
{!mediaSource?.TranscodingUrl ? ( <DropdownView showControls={showControls} />
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider> </VideoProvider>
</View> </View>
<View className="flex flex-row items-center space-x-2 "> <View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
<TouchableOpacity onPress={startPictureInPicture}>
<MaterialIcons
name="picture-in-picture"
size={24}
color="white"
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && !offline && ( {item?.Type === "Episode" && !offline && (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -592,13 +605,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity> </TouchableOpacity>
{/* )} */} {/* )} */}
<TouchableOpacity <TouchableOpacity
onPress={async () => { onPress={onClose}
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2" className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
> >
<Ionicons name="close" size={24} color="white" /> <Ionicons name="close" size={24} color="white" />

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

@@ -9,12 +9,15 @@ import React, {
useState, useState,
ReactNode, ReactNode,
useEffect, useEffect,
useMemo,
} from "react"; } from "react";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router";
interface VideoContextProps { interface VideoContextProps {
audioTracks: TrackInfo[] | null; audioTracks: Track[] | null;
subtitleTracks: TrackInfo[] | null; subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -45,30 +48,155 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleURL, setSubtitleURL,
setAudioTrack, setAudioTrack,
}) => { }) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null); const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>( const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
null
);
const ControlContext = useControlContext(); const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded; const isVideoLoaded = ControlContext?.isVideoLoaded;
const mediaSource = ControlContext?.mediaSource;
const allSubs =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const { itemId, audioIndex, bitrateValue, subtitleIndex } =
useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const onTextBasedSubtitle = useMemo(
() =>
allSubs.find(
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream
) || subtitleIndex === "-1",
[allSubs, subtitleIndex]
);
const setPlayerParams = ({
chosenAudioIndex = audioIndex,
chosenSubtitleIndex = subtitleIndex,
}: {
chosenAudioIndex?: string;
chosenSubtitleIndex?: string;
}) => {
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
const queryParams = new URLSearchParams({
itemId: itemId ?? "",
audioIndex: chosenAudioIndex,
subtitleIndex: chosenSubtitleIndex,
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrateValue,
}).toString();
//@ts-ignore
router.replace(`player/direct-player?${queryParams}`);
};
const setTrackParams = (
type: "audio" | "subtitle",
index: number,
serverIndex: number
) => {
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
// If we're transcoding and we're going from a image based subtitle
// to a text based subtitle, we need to change the player params.
const shouldChangePlayerParams =
type === "subtitle" &&
mediaSource?.TranscodingUrl &&
!onTextBasedSubtitle;
console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) {
setPlayerParams({
chosenSubtitleIndex: serverIndex.toString(),
});
return;
}
setTrack && setTrack(index);
router.setParams({
[paramKey]: serverIndex.toString(),
});
};
useEffect(() => { useEffect(() => {
const fetchTracks = async () => { const fetchTracks = async () => {
if ( if (getSubtitleTracks) {
getSubtitleTracks && const subtitleData = await getSubtitleTracks();
(subtitleTracks === null || subtitleTracks.length === 0)
) { let textSubIndex = 0;
const subtitles = await getSubtitleTracks(); const subtitles: Track[] = allSubs?.map((sub) => {
console.log("Getting embeded subtitles...", subtitles); // Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding
const shouldIncrement =
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
const displayTitle = sub.DisplayTitle || "Undefined Subtitle";
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
if (shouldIncrement) textSubIndex++;
return {
name: displayTitle,
index: sub.Index ?? -1,
originalIndex: finalIndex,
setTrack: () =>
shouldIncrement
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
: setPlayerParams({
chosenSubtitleIndex: sub.Index?.toString(),
}),
};
});
// Add a "Disable Subtitles" option
subtitles.unshift({
name: "Disable",
index: -1,
setTrack: () =>
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
? setTrackParams("subtitle", -1, -1)
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
});
setSubtitleTracks(subtitles); setSubtitleTracks(subtitles);
} }
if ( if (
getAudioTracks && getAudioTracks &&
(audioTracks === null || audioTracks.length === 0) (audioTracks === null || audioTracks.length === 0)
) { ) {
const audio = await getAudioTracks(); const audioData = await getAudioTracks();
setAudioTracks(audio); if (!audioData) return;
console.log("audioData", audioData);
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx)?.index ?? -1;
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () =>
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
};
}
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () =>
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
};
});
setAudioTracks(audioTracks);
} }
}; };
fetchTracks(); fetchTracks();

View File

@@ -0,0 +1,97 @@
import React from "react";
import { TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useVideoContext } from "../contexts/VideoContext";
import { useLocalSearchParams } from "expo-router";
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
offline = false,
}) => {
const videoContext = useVideoContext();
const { subtitleTracks, audioTracks } = videoContext;
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{subtitleTracks?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => sub.setTrack()}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => track.setTrack()}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
export default DropdownView;

View File

@@ -1,158 +0,0 @@
import React, { useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { router, useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
showControls,
offline = false,
}) => {
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const {
subtitleTracks,
audioTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
} = videoContext;
const allSubtitleTracksForDirectPlay = useMemo(() => {
if (mediaSource?.TranscodingUrl) return null;
const embeddedSubs =
subtitleTracks
?.map((s) => ({
name: s.name,
index: s.index,
deliveryUrl: undefined,
}))
.filter((sub) => !sub.name.endsWith("[External]")) || [];
const externalSubs =
mediaSource?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
).map((s) => ({
name: s.DisplayTitle! + " [External]",
index: s.Index!,
deliveryUrl: s.DeliveryUrl,
})) || [];
// Combine embedded subs with external subs only if not offline
if (!offline) {
return [...embeddedSubs, ...externalSubs] as (
| EmbeddedSubtitle
| ExternalSubtitle
)[];
}
return embeddedSubs as EmbeddedSubtitle[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
} else {
setSubtitleTrack && setSubtitleTrack(sub.index);
}
router.setParams({
subtitleIndex: sub.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
export default DropdownViewDirect;

View File

@@ -1,228 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { TranscodedSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams, useRouter } from "expo-router";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const router = useRouter();
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const { subtitleTracks, setSubtitleTrack } = videoContext;
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
const isOnTextSubtitle = useMemo(() => {
const res = Boolean(
mediaSource?.MediaStreams?.find(
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
) || subtitleIndex === "-1"
);
return res;
}, []);
const allSubs =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
const allSubtitleTracksForTranscodingStream = useMemo(() => {
const disableSubtitle = {
name: "Disable",
index: -1,
IsTextSubtitleStream: true,
} as TranscodedSubtitle;
if (isOnTextSubtitle) {
const textSubtitles =
subtitleTracks?.map((s) => ({
name: s.name,
index: s.index,
IsTextSubtitleStream: true,
})) || [];
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
return [disableSubtitle, ...sortedSubtitles];
}
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
IsTextSubtitleStream: x.IsTextSubtitleStream!,
}));
return [disableSubtitle, ...transcodedSubtitle];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
const changeToImageBasedSub = useCallback(
(subtitleIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
// Audio tracks for transcoding streams.
const allAudio =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
})) || [];
const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource, subtitleIndex, audioIndex]
);
return (
<View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.CheckboxItem
value={
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
}
key={`subtitle-item-${idx}`}
onValueChange={() => {
if (
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
)
return;
router.setParams({
subtitleIndex: subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString(),
});
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
changeToImageBasedSub(sub.index);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
if (audioIndex === track.index.toString()) return;
router.setParams({
audioIndex: track.index.toString(),
});
ChangeTranscodingAudio(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownView;

View File

@@ -13,7 +13,14 @@ type ExternalSubtitle = {
type TranscodedSubtitle = { type TranscodedSubtitle = {
name: string; name: string;
index: number; index: number;
deliveryUrl: string;
IsTextSubtitleStream: boolean; IsTextSubtitleStream: boolean;
}; };
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle }; type Track = {
name: string;
index: number;
setTrack: () => void;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };

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"
}, },
@@ -22,17 +32,27 @@
} }
}, },
"production": { "production": {
"channel": "0.25.0", "channel": "0.26.1",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"channel": "0.25.0", "channel": "0.26.1",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
} }
},
"production-apk-tv": {
"channel": "0.26.1",
"android": {
"buildType": "apk",
"image": "latest"
},
"env": {
"EXPO_TV": "1"
}
} }
}, },
"submit": { "submit": {

View File

@@ -1,15 +0,0 @@
--- expo.js.original 2024-11-10 09:08:19
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
@@ -19,10 +19,8 @@
const {
barStyle
} = androidStatusBar;
+ const android = props?.android || {};
const {
- android = {}
- } = props;
- const {
parentTheme = "Default"
} = android;
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
\ No newline at end of file

View File

@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
(x) => x.Type === "Audio" (x) => x.Type === "Audio"
)?.Index; )?.Index;
// 4. Get default bitrate // 4. Get default bitrate from settings or fallback to max
const bitrate = BITRATES[0]; const bitrate = settings?.defaultBitrate ?? BITRATES[0];
return { return {
defaultAudioIndex: defaultAudioIndex:

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();
items.forEach((item) => {
// Optimistic update // Optimistic update
queryClient.setQueryData(
["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);
@@ -80,8 +91,8 @@ export const useRemuxHlsToMp4 = () => {
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

@@ -26,9 +26,14 @@ class VlcPlayerModule : Module() {
"onVideoLoadStart", "onVideoLoadStart",
"onVideoLoadEnd", "onVideoLoadEnd",
"onVideoProgress", "onVideoProgress",
"onVideoError" "onVideoError",
"onPipStarted"
) )
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView -> AsyncFunction("play") { view: VlcPlayerView ->
view.play() view.play()
} }

View File

@@ -1,23 +1,40 @@
package expo.modules.vlcplayer package expo.modules.vlcplayer
import android.R
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.ViewGroup import androidx.annotation.RequiresApi
import android.widget.FrameLayout import androidx.core.app.ComponentActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleObserver
import android.net.Uri
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import org.videolan.libvlc.LibVLC import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media import org.videolan.libvlc.Media
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.MediaPlayer import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener { class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
private var libVLC: LibVLC? = null private var libVLC: LibVLC? = null
private var mediaPlayer: MediaPlayer? = null private var mediaPlayer: MediaPlayer? = null
@@ -30,6 +47,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private val onVideoProgress by EventDispatcher() private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher() private val onVideoStateChange by EventDispatcher()
private val onVideoLoadEnd by EventDispatcher() private val onVideoLoadEnd by EventDispatcher()
private val onPipStarted by EventDispatcher()
private var startPosition: Int? = 0 private var startPosition: Int? = 0
private var isMediaReady: Boolean = false private var isMediaReady: Boolean = false
@@ -44,9 +62,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
handler.postDelayed(this, updateInterval) handler.postDelayed(this, updateInterval)
} }
} }
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
private val actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> if (isPaused) play() else pause()
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
}
}
}
init { init {
setupView() setupView()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener { info ->
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
}
}
} }
private fun setupView() { private fun setupView() {
@@ -59,6 +100,76 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
Log.d("VlcPlayerView", "View setup complete") Log.d("VlcPlayerView", "View setup complete")
} }
@RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() {
val remoteActionFilter = IntentFilter()
val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
actions.addAll(
listOf(
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_rew),
"Rewind",
"Rewind Video",
PendingIntent.getBroadcast(
context,
0,
rewindIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_play),
"Play",
"Play Video",
PendingIntent.getBroadcast(
context,
0,
playPauseIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_ff),
"Skip",
"Skip Forward",
PendingIntent.getBroadcast(
context,
0,
forwardIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
)
)
)
ContextCompat.registerReceiver(
context,
actionReceiver,
remoteActionFilter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
private fun getPipParams(): PictureInPictureParams? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
var builder = PictureInPictureParams.Builder()
.setActions(actions)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder = builder.setAutoEnterEnabled(true)
}
return builder.build()
}
return null
}
fun setSource(source: Map<String, Any>) { fun setSource(source: Map<String, Any>) {
if (hasSource) { if (hasSource) {
mediaPlayer?.attachViews(videoLayout, null, false, false) mediaPlayer?.attachViews(videoLayout, null, false, false)
@@ -112,6 +223,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentActivity.enterPictureInPictureMode(getPipParams()!!)
}
}
fun play() { fun play() {
mediaPlayer?.play() mediaPlayer?.play()
isPaused = false isPaused = false
@@ -284,3 +401,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
} }
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
var context = this
while (context is ContextWrapper) {
if (context is androidx.activity.ComponentActivity) return context
context = context.baseContext
}
throw IllegalStateException("Failed to find ComponentActivity")
}

View File

@@ -1,7 +1,6 @@
import { import {
NativeModulesProxy,
EventEmitter, EventEmitter,
Subscription, EventSubscription,
} from "expo-modules-core"; } from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule"; import VlcPlayerModule from "./src/VlcPlayerModule";
@@ -19,13 +18,11 @@ import {
VlcPlayerViewRef, VlcPlayerViewRef,
} from "./src/VlcPlayer.types"; } from "./src/VlcPlayer.types";
const emitter = new EventEmitter( const emitter = new EventEmitter(VlcPlayerModule);
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
);
export function addPlaybackStateListener( export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void listener: (event: PlaybackStatePayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<PlaybackStatePayload>( return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged", "onPlaybackStateChanged",
listener listener
@@ -34,7 +31,7 @@ export function addPlaybackStateListener(
export function addVideoLoadStartListener( export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void listener: (event: VideoLoadStartPayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<VideoLoadStartPayload>( return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart", "onVideoLoadStart",
listener listener
@@ -43,7 +40,7 @@ export function addVideoLoadStartListener(
export function addVideoStateChangeListener( export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void listener: (event: VideoStateChangePayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<VideoStateChangePayload>( return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange", "onVideoStateChange",
listener listener
@@ -52,7 +49,7 @@ export function addVideoStateChangeListener(
export function addVideoProgressListener( export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void listener: (event: VideoProgressPayload) => void
): Subscription { ): EventSubscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener); return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
} }

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'VlcPlayer' s.name = 'VlcPlayer'
s.version = '1.0.0' s.version = '4.0.0a10'
s.summary = 'A sample project summary' s.summary = 'A sample project summary'
s.description = 'A sample project description' s.description = 'A sample project description'
s.author = '' s.author = ''
@@ -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 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version
# Swift/Objective-C compatibility # Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {

View File

@@ -16,27 +16,20 @@ public class VlcPlayerModule: Module {
} }
} }
// Prop("muted") { (view: VlcPlayerView, muted: Bool) in
// view.setMuted(muted)
// }
// Prop("volume") { (view: VlcPlayerView, volume: Int) in
// view.setVolume(volume)
// }
// Prop("videoAspectRatio") { (view: VlcPlayerView, ratio: String) in
// view.setVideoAspectRatio(ratio)
// }
Events( Events(
"onPlaybackStateChanged", "onPlaybackStateChanged",
"onVideoStateChange", "onVideoStateChange",
"onVideoLoadStart", "onVideoLoadStart",
"onVideoLoadEnd", "onVideoLoadEnd",
"onVideoProgress", "onVideoProgress",
"onVideoError" "onVideoError",
"onPipStarted"
) )
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayerView) in AsyncFunction("play") { (view: VlcPlayerView) in
view.play() view.play()
} }
@@ -69,14 +62,6 @@ public class VlcPlayerModule: Module {
return view.getSubtitleTracks() return view.getSubtitleTracks()
} }
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
// view.setVideoCropGeometry(geometry)
// }
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
// return view.getVideoCropGeometry()
// }
AsyncFunction("setSubtitleURL") { AsyncFunction("setSubtitleURL") {
(view: VlcPlayerView, url: String, name: String) in (view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name) view.setSubtitleURL(url, name: name)

View File

@@ -1,50 +1,171 @@
import ExpoModulesCore import ExpoModulesCore
import MobileVLCKit
import UIKit import UIKit
import VLCKit
public class VLCPlayerView: UIView {
func setupView(parent: UIView) {
self.backgroundColor = .black
self.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
self.topAnchor.constraint(equalTo: parent.topAnchor),
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
])
}
public override func layoutSubviews() {
super.layoutSubviews()
for subview in subviews {
subview.frame = bounds
}
}
}
class VLCPlayerWrapper: NSObject {
private var lastProgressCall = Date().timeIntervalSince1970
public var player: VLCMediaPlayer = VLCMediaPlayer()
private var updatePlayerState: (() -> Void)?
private var updateVideoProgress: (() -> Void)?
private var playerView: VLCPlayerView = VLCPlayerView()
public weak var pipController: VLCPictureInPictureWindowControlling?
override public init() {
super.init()
player.delegate = self
player.drawable = self
player.scaleFactor = 0
}
public func setup(
parent: UIView,
updatePlayerState: (() -> Void)?,
updateVideoProgress: (() -> Void)?
) {
self.updatePlayerState = updatePlayerState
self.updateVideoProgress = updateVideoProgress
player.delegate = self
parent.addSubview(playerView)
playerView.setupView(parent: parent)
}
public func getPlayerView() -> UIView {
return playerView
}
}
// MARK: - VLCPictureInPictureDrawable
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
return self
}
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
{
return { [weak self] controller in
self?.pipController = controller
}
}
}
// MARK: - VLCPictureInPictureMediaControlling
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
func mediaTime() -> Int64 {
return player.time.value?.int64Value ?? 0
}
func mediaLength() -> Int64 {
return player.media?.length.value?.int64Value ?? 0
}
func play() {
player.play()
}
func pause() {
player.pause()
}
func seek(by offset: Int64, completion: @escaping () -> Void) {
player.jump(withOffset: Int32(offset), completion: completion)
}
func isMediaSeekable() -> Bool {
return player.isSeekable
}
func isMediaPlaying() -> Bool {
return player.isPlaying
}
}
// MARK: - VLCDrawable
extension VLCPlayerWrapper: VLCDrawable {
public func addSubview(_ view: UIView) {
playerView.addSubview(view)
}
public func bounds() -> CGRect {
return playerView.bounds
}
}
// MARK: - VLCMediaPlayerDelegate
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let timeNow = Date().timeIntervalSince1970
if timeNow - self.lastProgressCall >= 1 {
self.lastProgressCall = timeNow
self.updateVideoProgress?()
}
}
}
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.updatePlayerState?()
guard let pipController = self.pipController else { return }
pipController.invalidatePlaybackState()
}
}
}
// MARK: - VLCMediaDelegate
extension VLCPlayerWrapper: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
class VlcPlayerView: ExpoView { class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer? private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState?
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = [] private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0 private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]? private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970 private var externalSubtitles: [[String: String]]?
var hasSource = false var hasSource = false
// MARK: - Initialization // MARK: - Initialization
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
setupView() setupVLC()
setupNotifications() setupNotifications()
} }
// MARK: - Setup // MARK: - Setup
private func setupVLC() {
private func setupView() { vlc.setup(
DispatchQueue.main.async { parent: self,
self.backgroundColor = .black updatePlayerState: updatePlayerState,
self.videoView = UIView() updateVideoProgress: updateVideoProgress
self.videoView?.translatesAutoresizingMaskIntoConstraints = false )
if let videoView = self.videoView {
self.addSubview(videoView)
NSLayoutConstraint.activate([
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
videoView.topAnchor.constraint(equalTo: self.topAnchor),
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
}
} }
private func setupNotifications() { private func setupNotifications() {
@@ -57,37 +178,44 @@ class VlcPlayerView: ExpoView {
} }
// MARK: - Public Methods // MARK: - Public Methods
func startPictureInPicture() {
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
self.onPipStarted?(["pipStarted": isStarted])
}
self.vlc.pipController?.startPictureInPicture()
}
@objc func play() { @objc func play() {
self.mediaPlayer?.play() self.vlc.player.play()
self.isPaused = false self.isPaused = false
print("Play") print("Play")
} }
@objc func pause() { @objc func pause() {
self.mediaPlayer?.pause() self.vlc.player.pause()
self.isPaused = true self.isPaused = true
} }
@objc func seekTo(_ time: Int32) { @objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return } let wasPlaying = vlc.player.isPlaying
let wasPlaying = player.isPlaying
if wasPlaying { if wasPlaying {
self.pause() self.pause()
} }
if let duration = player.media?.length.intValue { if let duration = vlc.player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)") print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end // If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime) vlc.player.time = VLCTime(int: seekTime)
self.updatePlayerState()
// Let mediaPlayerStateChanged handle play state change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if wasPlaying { if wasPlaying {
self.play() self.play()
} }
self.updatePlayerState() }
} else { } else {
print("Error: Unable to retrieve video duration") print("Error: Unable to retrieve video duration")
} }
@@ -100,11 +228,17 @@ class VlcPlayerView: ExpoView {
return return
} }
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:] var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String] self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? [] let initOptions: [String] = source["initOptions"] as? [String] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0 self.startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(self.startPosition)") self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
for item in initOptions {
let option = item.components(separatedBy: "=")
mediaOptions.updateValue(
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
}
guard let uri = source["uri"] as? String, !uri.isEmpty else { guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI") print("Error: Invalid or empty URI")
@@ -116,12 +250,8 @@ class VlcPlayerView: ExpoView {
let isNetwork = source["isNetwork"] as? Bool ?? false let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()]) self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0
let media: VLCMedia let media: VLCMedia!
if isNetwork { if isNetwork {
print("Loading network file: \(uri)") print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!) media = VLCMedia(url: URL(string: uri)!)
@@ -137,38 +267,41 @@ class VlcPlayerView: ExpoView {
print("Debug: Media options: \(mediaOptions)") print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions) media.addOptions(mediaOptions)
self.mediaPlayer?.media = media self.vlc.player.media = media
self.setInitialExternalSubtitles()
self.hasSource = true self.hasSource = true
if autoplay { if autoplay {
print("Playing...") print("Playing...")
self.play() self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
} }
} }
} }
@objc func setAudioTrack(_ trackIndex: Int) { @objc func setAudioTrack(_ trackIndex: Int) {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex) print("Setting audio track: \(trackIndex)")
let track = self.vlc.player.audioTracks[trackIndex]
track.isSelectedExclusively = true
} }
@objc func getAudioTracks() -> [[String: Any]]? { @objc func getAudioTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.audioTrackNames, return vlc.player.audioTracks.enumerated().map {
let trackIndexes = mediaPlayer?.audioTrackIndexes return ["name": $1.trackName, "index": $0]
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
} }
} }
@objc func setSubtitleTrack(_ trackIndex: Int) { @objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)") print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex) if trackIndex == -1 {
print( print("Debug: Disabling all subtitles")
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)" for track in self.vlc.player.textTracks {
) track.isSelected = false
}
return
}
let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true
print("Debug: Current subtitle track index after setting: \(track.trackName)")
} }
@objc func setSubtitleURL(_ subtitleURL: String, name: String) { @objc func setSubtitleURL(_ subtitleURL: String, name: String) {
@@ -176,66 +309,35 @@ class VlcPlayerView: ExpoView {
print("Error: Invalid subtitle URL") print("Error: Invalid subtitle URL")
return return
} }
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true) if result == 0 {
if let result = result { let internalName = "Track \(self.customSubtitles.count)"
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name)) self.customSubtitles.append((internalName: internalName, originalName: name))
print("Subtitle added with result: \(result) \(internalName)")
} else { } else {
print("Failed to add subtitle") print("Failed to add subtitle")
} }
} }
@objc func getSubtitleTracks() -> [[String: Any]]? { @objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else { if self.vlc.player.textTracks.count == 0 {
return nil return nil
} }
let count = mediaPlayer.numberOfSubtitlesTracks print("Debug: Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
print("Debug: Number of subtitle tracks: \(count)") let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
if let customSubtitle = customSubtitles.first(where: {
guard count > 0 else { $0.internalName == track.trackName
return nil }) {
} return ["name": customSubtitle.originalName, "index": index]
var tracks: [[String: Any]] = []
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else { } else {
tracks.append(["name": name, "index": index.intValue]) return ["name": track.trackName, "index": index]
} }
} }
}
print("Debug: Subtitle tracks: \(tracks)") print("Debug: Subtitle tracks: \(tracks)")
return tracks return tracks
} }
private func setSubtitleTrackByName(_ trackName: String) {
guard let mediaPlayer = self.mediaPlayer else { return }
// Get the subtitle tracks and their indexes
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if name.starts(with: trackName) {
let trackIndex = index.intValue
print("Track Index setting to: \(trackIndex)")
setSubtitleTrack(trackIndex)
return
}
}
}
print("Track not found for name: \(trackName)")
}
@objc func stop(completion: (() -> Void)? = nil) { @objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else { guard !isStopping else {
completion?() completion?()
@@ -263,58 +365,67 @@ class VlcPlayerView: ExpoView {
} }
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
self.setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
private func performStop(completion: (() -> Void)? = nil) { private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player // Stop the media player
mediaPlayer?.stop() vlc.player.stop()
// Remove observer // Remove observer
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
// Clear the video view // Clear the video view
videoView?.removeFromSuperview() vlc.getPlayerView().removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
isStopping = false isStopping = false
completion?() completion?()
} }
private func updateVideoProgress() { private func updateVideoProgress() {
guard let player = self.mediaPlayer else { return } guard let media = self.vlc.player.media else { return }
let currentTimeMs = player.time.intValue let currentTimeMs = self.vlc.player.time.intValue
let durationMs = player.media?.length.intValue ?? 0 let durationMs = self.vlc.player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)") print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([ self.onVideoProgress?([
"currentTime": currentTimeMs, "currentTime": currentTimeMs,
"duration": durationMs, "duration": durationMs,
]) ])
} }
private func updatePlayerState() {
let player = self.vlc.player
self.onVideoStateChange?([
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
"isPlaying": player.isPlaying,
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
"state": player.state.description,
])
} }
// MARK: - Expo Events // MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock? @objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock? @objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock? @objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock? @objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock? @objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock? @objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization // MARK: - Deinitialization
@@ -323,67 +434,6 @@ class VlcPlayerView: ExpoView {
} }
} }
extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
// self?.updateVideoProgress()
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
}
}
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
}
private func updatePlayerState() {
guard let player = self.mediaPlayer else { return }
let currentState = player.state
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
}
}
extension VlcPlayerView: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
extension VLCMediaPlayerState { extension VLCMediaPlayerState {
var description: String { var description: String {
switch self { switch self {
@@ -392,9 +442,7 @@ extension VLCMediaPlayerState {
case .playing: return "Playing" case .playing: return "Playing"
case .paused: return "Paused" case .paused: return "Paused"
case .stopped: return "Stopped" case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error" case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown" @unknown default: return "Unknown"
} }
} }

View File

@@ -24,6 +24,12 @@ export type VideoLoadStartPayload = {
}; };
}; };
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload; export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload; export type VideoProgressPayload = ProgressUpdatePayload;
@@ -33,7 +39,7 @@ export type VlcPlayerSource = {
type?: string; type?: string;
isNetwork?: boolean; isNetwork?: boolean;
autoplay?: boolean; autoplay?: boolean;
externalTrack?: { name: string, DeliveryUrl: string }; externalSubtitles: { name: string; DeliveryUrl: string }[];
initOptions?: any[]; initOptions?: any[];
mediaOptions?: { [key: string]: any }; mediaOptions?: { [key: string]: any };
startPosition?: number; startPosition?: number;
@@ -64,9 +70,11 @@ export type VlcPlayerViewProps = {
onVideoLoadStart?: (event: VideoLoadStartPayload) => void; onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void; onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void; onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
}; };
export interface VlcPlayerViewRef { export interface VlcPlayerViewRef {
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>; play: () => Promise<void>;
pause: () => Promise<void>; pause: () => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;

View File

@@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const nativeRef = React.useRef<NativeViewRef>(null); const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture()
},
play: async () => { play: async () => {
await nativeRef.current?.play(); await nativeRef.current?.play();
}, },
@@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoProgress, onVideoProgress,
onVideoLoadEnd, onVideoLoadEnd,
onVideoError, onVideoError,
onPipStarted,
...otherProps ...otherProps
} = props; } = props;
@@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoStateChange={onVideoStateChange} onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress} onVideoProgress={onVideoProgress}
onVideoError={onVideoError} onVideoError={onVideoError}
onPipStarted={onPipStarted}
/> />
); );
} }

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": "1.23.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-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-native": "0.74.5", "react-native": "npm:react-native-tvos@~0.77.0-0",
"react-native-awesome-slider": "^2.5.6", "react-native-awesome-slider": "^2.9.0",
"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.22.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.5.1",
"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.1.0",
"react-native-screens": "3.31.1", "react-native-screens": "~4.5.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", "@babel/core": "^7.26.8",
"@react-native-community/cli": "15.1.3",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/react": "~18.2.79", "@types/react": "~18.3.12",
"@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,38 @@
const { withAndroidManifest: NativeAndroidManifest } = require("@expo/config-plugins");
const withAndroidManifest = (config) =>
NativeAndroidManifest(config, async (config) => {
const mainApplication = config.modResults.manifest.application[0];
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
mainApplication.activity = [];
}
const googleCastActivityExists = mainApplication.activity.some(activity =>
activity.$?.["android:name"] === "com.reactnative.googlecast.RNGCExpandedControllerActivity"
);
// Only add the activity if it doesn't already exist
if (!googleCastActivityExists) {
mainApplication.activity.push({
$: {
"android:name": "com.reactnative.googlecast.RNGCExpandedControllerActivity",
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
"android:launchMode": "singleTask",
},
});
}
const mainActivity = mainApplication.activity.find(activity =>
activity.$?.["android:name"] === ".MainActivity"
);
if (mainActivity) {
mainActivity.$["android:supportsPictureInPicture"] = "true"
}
return config;
});
module.exports = withAndroidManifest;

View File

@@ -1,34 +0,0 @@
const { withAndroidManifest } = require("@expo/config-plugins");
const withGoogleCastActivity = (config) =>
withAndroidManifest(config, async (config) => {
const mainApplication = config.modResults.manifest.application[0];
// Initialize activity array if it doesn't exist
if (!mainApplication.activity) {
mainApplication.activity = [];
}
// Check if the activity already exists
const activityExists = mainApplication.activity.some(
(activity) =>
activity.$?.["android:name"] ===
"com.reactnative.googlecast.RNGCExpandedControllerActivity"
);
// Only add the activity if it doesn't already exist
if (!activityExists) {
mainApplication.activity.push({
$: {
"android:name":
"com.reactnative.googlecast.RNGCExpandedControllerActivity",
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
"android:launchMode": "singleTask",
},
});
}
return config;
});
module.exports = withGoogleCastActivity;

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 { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; 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,7 +133,11 @@ 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(
t("home.downloads.toasts.item_is_ready_to_be_downloaded", {
item: job.item.Name,
}),
{
action: { action: {
label: t("home.downloads.toasts.go_to_downloads"), label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => { onClick: () => {
@@ -149,7 +145,8 @@ function useDownloadProvider() {
toast.dismiss(); 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,7 +223,11 @@ function useDownloadProvider() {
}, },
}); });
toast.info(t("home.downloads.toasts.download_stated_for_item", {item: process.item.Name}), { toast.info(
t("home.downloads.toasts.download_stated_for_item", {
item: process.item.Name,
}),
{
action: { action: {
label: t("home.downloads.toasts.go_to_downloads"), label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => { onClick: () => {
@@ -234,11 +235,12 @@ function useDownloadProvider() {
toast.dismiss(); 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,7 +279,11 @@ 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(
t("home.downloads.toasts.download_completed_for_item", {
item: process.item.Name,
}),
{
duration: 3000, duration: 3000,
action: { action: {
label: t("home.downloads.toasts.go_to_downloads"), label: t("home.downloads.toasts.go_to_downloads"),
@@ -286,15 +292,16 @@ function useDownloadProvider() {
toast.dismiss(); 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,7 +371,11 @@ 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(
t("home.downloads.toasts.queued_item_for_optimization", {
item: item.Name,
}),
{
action: { action: {
label: t("home.downloads.toasts.go_to_downloads"), label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => { onClick: () => {
@@ -367,7 +383,8 @@ function useDownloadProvider() {
toast.dismiss(); 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;
@@ -60,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.25.0" }, clientInfo: { name: "Streamyfin", version: "0.26.1" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -105,7 +109,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.25.0"`, }, DeviceId="${deviceId}", Version="0.26.1"`,
}; };
}, [deviceId]); }, [deviceId]);
@@ -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

@@ -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": {
@@ -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",
@@ -334,14 +334,14 @@
"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,16 +373,16 @@
"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"
} }
}, },
@@ -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",

457
translations/tr.json Normal file
View File

@@ -0,0 +1,457 @@
{
"login": {
"username_required": "Kullanıcı adı gereklidir",
"error_title": "Hata",
"login_title": "Giriş yap",
"login_to_title": " 'e giriş yap",
"username_placeholder": "Kullanıcı adı",
"password_placeholder": "Şifre",
"login_button": "Giriş yap",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Giriş yapmak için {{code}} kodunu girin",
"failed_to_initiate_quick_connect": "Quick Connect başlatılamadı",
"got_it": "Anlaşıldı",
"connection_failed": "Bağlantı başarısız",
"could_not_connect_to_server": "Sunucuya bağlanılamadı. Lütfen URL'yi ve ağ bağlantınızı kontrol edin",
"an_unexpected_error_occured": "Beklenmedik bir hata oluştu",
"change_server": "Sunucuyu değiştir",
"invalid_username_or_password": "Geçersiz kullanıcı adı veya şifre",
"user_does_not_have_permission_to_log_in": "Kullanıcının giriş yapma izni yok",
"server_is_taking_too_long_to_respond_try_again_later": "Sunucu yanıt vermekte çok uzun sürüyor, lütfen tekrar deneyin",
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen tekrar deneyin.",
"there_is_a_server_error": "Sunucu hatası var",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?"
},
"server": {
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL'sini girin",
"server_url_placeholder": "http(s)://sunucunuz.com",
"connect_button": "Bağlan",
"previous_servers": "Önceki sunucular",
"clear_button": "Temizle",
"search_for_local_servers": "Yerel sunucuları ara",
"searching": "Aranıyor...",
"servers": "Sunucular"
},
"home": {
"no_internet": "İnternet Yok",
"no_items": "Öge Yok",
"no_internet_message": "Endişelenmeyin, hala\ndownloaded içerik izleyebilirsiniz.",
"go_to_downloads": "İndirmelere Git",
"oops": "Hups!",
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapın ve tekrar giriş yapın.",
"continue_watching": "İzlemeye Devam Et",
"next_up": "Sonraki",
"recently_added_in": "{{libraryName}}'de Yakınlarda Eklendi",
"suggested_movies": "Önerilen Filmler",
"suggested_episodes": "Önerilen Bölümler",
"intro": {
"welcome_to_streamyfin": "Streamyfin'e Hoş Geldiniz",
"a_free_and_open_source_client_for_jellyfin": "Jellyfin için ücretsiz ve açık kaynak bir istemci.",
"features_title": "Özellikler",
"features_description": "Streamyfin birçok özelliğe sahip ve ayarlar menüsünde bulabileceğiniz çeşitli yazılımlarla entegre olabiliyor. Bunlar arasında şunlar bulunuyor:",
"jellyseerr_feature_description": "Jellyseerr örneğinizle bağlantı kurun ve uygulama içinde doğrudan film talep edin.",
"downloads_feature_title": "İndirmeler",
"downloads_feature_description": "Filmleri ve TV dizilerini çevrimdışı izlemek için indirin. Varsayılan yöntemi veya dosyaları arka planda indirmek için optimize sunucuyu kurabilirsiniz.",
"chromecast_feature_description": "Filmleri ve TV dizilerini Chromecast cihazlarınıza aktarın.",
"centralised_settings_plugin_title": "Merkezi Ayarlar Eklentisi",
"centralised_settings_plugin_description": "Jellyfin sunucunuzda merkezi bir yerden ayarları yapılandırın. Tüm istemci ayarları tüm kullanıcılar için otomatik olarak senkronize edilecektir.",
"done_button": "Tamam",
"go_to_settings_button": "Ayrıntılara Git",
"read_more": "Daha fazla oku"
},
"settings": {
"settings_title": "Ayarlar",
"log_out_button": ıkış Yap",
"user_info": {
"user_info_title": "Kullanıcı Bilgisi",
"user": "Kullanıcı",
"server": "Sunucu",
"token": "Token",
"app_version": "Uygulama Sürümü"
},
"quick_connect": {
"quick_connect_title": "Hızlı Bağlantı",
"authorize_button": "Hızlı Bağlantıyı Yetkilendir",
"enter_the_quick_connect_code": "Hızlı bağlantı kodunu girin...",
"success": "Başarılı",
"quick_connect_autorized": "Hızlı Bağlantı Yetkilendirildi",
"error": "Hata",
"invalid_code": "Geçersiz kod",
"authorize": "Yetkilendir"
},
"media_controls": {
"media_controls_title": "Medya Kontrolleri",
"forward_skip_length": "İleri Sarma Uzunluğu",
"rewind_length": "Geri Sarma Uzunluğu",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Ses",
"set_audio_track": "Önceki Öğeden Ses Parçası Ayarla",
"audio_language": "Ses Dili",
"audio_hint": "Varsayılan ses dilini seçin.",
"none": "Yok",
"language": "Dil"
},
"subtitles": {
"subtitle_title": "Altyazılar",
"subtitle_language": "Altyazı Dili",
"subtitle_mode": "Altyazı Modu",
"set_subtitle_track": "Önceki Öğeden Altyazı Parçası Ayarla",
"subtitle_size": "Altyazı Boyutu",
"subtitle_hint": "Altyazı tercihini yapılandırın.",
"none": "Yok",
"language": "Dil",
"loading": "Yükleniyor",
"modes": {
"Default": "Varsayılan",
"Smart": "Akıllı",
"Always": "Her Zaman",
"None": "Yok",
"OnlyForced": "Sadece Zorunlu"
}
},
"other": {
"other_title": "Diğer",
"auto_rotate": "Otomatik Döndürme",
"video_orientation": "Video Yönü",
"orientation": "Yön",
"orientations": {
"DEFAULT": "Varsayılan",
"ALL": "Tümü",
"PORTRAIT": "Dikey",
"PORTRAIT_UP": "Dikey Yukarı",
"PORTRAIT_DOWN": "Dikey Aşağı",
"LANDSCAPE": "Yatay",
"LANDSCAPE_LEFT": "Yatay Sol",
"LANDSCAPE_RIGHT": "Yatay Sağ",
"OTHER": "Diğer",
"UNKNOWN": "Bilinmeyen"
},
"safe_area_in_controls": "Kontrollerde Güvenli Alan",
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
"hide_libraries": "Kütüphaneleri Gizle",
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak"
},
"downloads": {
"downloads_title": "İndirmeler",
"download_method": "İndirme Yöntemi",
"remux_max_download": "Remux max indirme",
"auto_download": "Otomatik İndirme",
"optimized_versions_server": "Optimize edilmiş sürümler sunucusu",
"save_button": "Kaydet",
"optimized_server": "Optimize Sunucu",
"optimized": "Optimize",
"default": "Varsayılan",
"optimized_version_hint": "Optimize sunucusu için URL girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
"read_more_about_optimized_server": "Optimize sunucusu hakkında daha fazla oku.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "Eklentiler",
"jellyseerr": {
"jellyseerr_warning": "Bu entegrasyon erken aşamalardadır. Değişiklikler olabilir.",
"server_url": "Sunucu URL'si",
"server_url_hint": "Örnek: http(s)://your-host.url\n(port gerekiyorsa ekleyin)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Şifre",
"password_placeholder": "Jellyfin kullanıcısı {{username}} için şifre girin",
"save_button": "Kaydet",
"clear_button": "Temizle",
"login_button": "Giriş Yap",
"total_media_requests": "Toplam medya istekleri",
"movie_quota_limit": "Film kota limiti",
"movie_quota_days": "Film kota günleri",
"tv_quota_limit": "TV kota limiti",
"tv_quota_days": "TV kota günleri",
"reset_jellyseerr_config_button": "Jellyseerr yapılandırmasını sıfırla",
"unlimited": "Sınırsız"
},
"marlin_search": {
"enable_marlin_search": "Marlin Aramasını Etkinleştir ",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Marlin sunucu URL'sini girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
"read_more_about_marlin": "Marlin hakkında daha fazla oku.",
"save_button": "Kaydet",
"toasts": {
"saved": "Kaydedildi"
}
}
},
"storage": {
"storage_title": "Depolama",
"app_usage": "Uygulama {{usedSpace}}%",
"device_usage": "Cihaz {{availableSpace}}%",
"size_used": "{{used}} / {{total}} kullanıldı",
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil"
},
"intro": {
"show_intro": "Tanıtımı Göster",
"reset_intro": "Tanıtımı Sıfırla"
},
"logs": {
"logs_title": "Günlükler",
"no_logs_available": "Günlükler mevcut değil",
"delete_all_logs": "Tüm günlükleri sil"
},
"languages": {
"title": "Diller",
"app_language": "Uygulama dili",
"app_language_description": "Uygulama dilini seçin.",
"system": "Sistem"
},
"toasts": {
"error_deleting_files": "Dosyalar silinirken hata oluştu",
"background_downloads_enabled": "Arka plan indirmeleri etkinleştirildi",
"background_downloads_disabled": "Arka plan indirmeleri devre dışı bırakıldı",
"connected": "Bağlandı",
"could_not_connect": "Bağlanılamadı",
"invalid_url": "Geçersiz URL"
}
},
"downloads": {
"downloads_title": "İndirilenler",
"tvseries": "Diziler",
"movies": "Filmler",
"queue": "Sıra",
"queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır",
"no_items_in_queue": "Sırada öğe yok",
"no_downloaded_items": "İndirilen öğe yok",
"delete_all_movies_button": "Tüm Filmleri Sil",
"delete_all_tvseries_button": "Tüm Dizileri Sil",
"delete_all_button": "Tümünü Sil",
"active_download": "Aktif indirme",
"no_active_downloads": "Aktif indirme yok",
"active_downloads": "Aktif indirmeler",
"new_app_version_requires_re_download": "Yeni uygulama sürümü yeniden indirme gerektiriyor",
"new_app_version_requires_re_download_description": "Yeni güncelleme, içeriğin yeniden indirilmesini gerektiriyor. Lütfen tüm indirilen içerikleri kaldırıp tekrar deneyin.",
"back": "Geri",
"delete": "Sil",
"something_went_wrong": "Bir şeyler ters gitti",
"could_not_get_stream_url_from_jellyfin": "Jellyfin'den yayın URL'si alınamadı",
"eta": "Tahmini Süre {{eta}}",
"methods": "Yöntemler",
"toasts": {
"you_are_not_allowed_to_download_files": "Dosyaları indirme izniniz yok.",
"deleted_all_movies_successfully": "Tüm filmler başarıyla silindi!",
"failed_to_delete_all_movies": "Filmler silinemedi",
"deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!",
"failed_to_delete_all_tvseries": "Diziler silinemedi",
"download_cancelled": "İndirme iptal edildi",
"could_not_cancel_download": "İndirme iptal edilemedi",
"download_completed": "İndirme tamamlandı",
"download_started_for": "{{item}} için indirme başlatıldı",
"item_is_ready_to_be_downloaded": "{{item}} indirmeye hazır",
"download_stated_for_item": "{{item}} için indirme başlatıldı",
"download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}",
"download_completed_for_item": "{{item}} için indirme tamamlandı",
"queued_item_for_optimization": "{{item}} optimizasyon için sıraya alındı",
"failed_to_start_download_for_item": "{{item}} için indirme başlatılamadı: {{message}}",
"server_responded_with_status_code": "Sunucu şu durum koduyla yanıt verdi: {{statusCode}}",
"no_response_received_from_server": "Sunucudan yanıt alınamadı",
"error_setting_up_the_request": "İstek ayarlanırken hata oluştu",
"failed_to_start_download_for_item_unexpected_error": "{{item}} için indirme başlatılamadı: Beklenmeyen hata",
"all_files_folders_and_jobs_deleted_successfully": "Tüm dosyalar, klasörler ve işler başarıyla silindi",
"an_error_occured_while_deleting_files_and_jobs": "Dosyalar ve işler silinirken hata oluştu",
"go_to_downloads": "İndirmelere git"
}
}
},
"search": {
"search_here": "Burada ara...",
"search": "Ara...",
"x_items": "{{count}} öge(ler)",
"library": "Kütüphane",
"discover": "Keşfet",
"no_results": "Sonuç bulunamadı",
"no_results_found_for": "\"{{query}}\" için sonuç bulunamadı",
"movies": "Filmler",
"series": "Diziler",
"episodes": "Bölümler",
"collections": "Koleksiyonlar",
"actors": "Oyuncular",
"request_movies": "Film Talep Et",
"request_series": "Dizi Talep Et",
"recently_added": "Son Eklenenler",
"recent_requests": "Son Talepler",
"plex_watchlist": "Plex İzleme Listesi",
"trending": "Şu An Popüler",
"popular_movies": "Popüler Filmler",
"movie_genres": "Film Türleri",
"upcoming_movies": "Yaklaşan Filmler",
"studios": "Stüdyolar",
"popular_tv": "Popüler Diziler",
"tv_genres": "Dizi Türleri",
"upcoming_tv": "Yaklaşan Diziler",
"networks": "Ağlar",
"tmdb_movie_keyword": "TMDB Film Anahtar Kelimesi",
"tmdb_movie_genre": "TMDB Film Türü",
"tmdb_tv_keyword": "TMDB Dizi Anahtar Kelimesi",
"tmdb_tv_genre": "TMDB Dizi Türü",
"tmdb_search": "TMDB Arama",
"tmdb_studio": "TMDB Stüdyo",
"tmdb_network": "TMDB Ağ",
"tmdb_movie_streaming_services": "TMDB Film Yayın Servisleri",
"tmdb_tv_streaming_services": "TMDB Dizi Yayın Servisleri"
},
"library": {
"no_items_found": "Öğe bulunamadı",
"no_results": "Sonuç bulunamadı",
"no_libraries_found": "Kütüphane bulunamadı",
"item_types": {
"movies": "filmler",
"series": "diziler",
"boxsets": "koleksiyonlar",
"items": "ögeler"
},
"options": {
"display": "Görüntüleme",
"row": "Satır",
"list": "Liste",
"image_style": "Görsel stili",
"poster": "Poster",
"cover": "Kapak",
"show_titles": "Başlıkları göster",
"show_stats": "İstatistikleri göster"
},
"filters": {
"genres": "Türler",
"years": "Yıllar",
"sort_by": "Sırala",
"sort_order": "Sıralama düzeni",
"tags": "Etiketler"
}
},
"favorites": {
"series": "Diziler",
"movies": "Filmler",
"episodes": "Bölümler",
"videos": "Videolar",
"boxsets": "Koleksiyonlar",
"playlists": "Çalma listeleri"
},
"custom_links": {
"no_links": "Bağlantı yok"
},
"player": {
"error": "Hata",
"failed_to_get_stream_url": "Yayın URL'si alınamadı",
"an_error_occured_while_playing_the_video": "Video oynatılırken bir hata oluştu. Ayarlardaki günlüklere bakın.",
"client_error": "İstemci hatası",
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
"message_from_server": "Sunucudan mesaj: {{message}}",
"video_has_finished_playing": "Video oynatıldı!",
"no_video_source": "Video kaynağı yok...",
"next_episode": "Sonraki bölüm",
"refresh_tracks": "Parçaları yenile",
"subtitle_tracks": "Altyazı Parçaları:",
"audio_tracks": "Ses Parçaları:",
"playback_state": "Oynatma Durumu:",
"no_data_available": "Veri bulunamadı",
"index": "İndeks:"
},
"item_card": {
"next_up": "Sıradaki",
"no_items_to_display": "Görüntülenecek öğe yok",
"cast_and_crew": "Oyuncular & Ekip",
"series": "Dizi",
"seasons": "Sezonlar",
"season": "Sezon",
"no_episodes_for_this_season": "Bu sezona ait bölüm yok",
"overview": "Özet",
"more_with": "Daha fazla {{name}}",
"similar_items": "Benzer ögeler",
"no_similar_items_found": "Benzer öge bulunamadı",
"video": "Video",
"more_details": "Daha fazla detay",
"quality": "Kalite",
"audio": "Ses",
"subtitles": "Altyazı",
"show_more": "Daha fazla göster",
"show_less": "Daha az göster",
"appeared_in": "Şurada yer aldı",
"could_not_load_item": "Öge yüklenemedi",
"none": "Hiçbiri",
"download": {
"download_season": "Sezonu indir",
"download_series": "Diziyi indir",
"download_episode": "Bölümü indir",
"download_movie": "Filmi indir",
"download_x_item": "{{item_count}} tane ögeyi indir",
"download_button": "İndir",
"using_optimized_server": "Optimize edilmiş sunucu kullanılıyor",
"using_default_method": "Varsayılan yöntem kullanılıyor"
}
},
"live_tv": {
"next": "Sonraki",
"previous": "Önceki",
"live_tv": "Canlı TV",
"coming_soon": "Yakında",
"on_now": "Şu anda yayında",
"shows": "Programlar",
"movies": "Filmler",
"sports": "Spor",
"for_kids": "Çocuklar İçin",
"news": "Haberler"
},
"jellyseerr": {
"confirm": "Onayla",
"cancel": "İptal",
"yes": "Evet",
"whats_wrong": "Problem nedir?",
"issue_type": "Sorun türü",
"select_an_issue": "Bir sorun seçin",
"types": "Türler",
"describe_the_issue": "(isteğe bağlı) Sorunu açıklayın...",
"submit_button": "Gönder",
"report_issue_button": "Sorunu bildir",
"request_button": "Talep et",
"are_you_sure_you_want_to_request_all_seasons": "Tüm sezonları talep etmek istediğinizden emin misiniz?",
"failed_to_login": "Giriş yapılamadı",
"cast": "Oyuncular",
"details": "Detaylar",
"status": "Durum",
"original_title": "Orijinal Başlık",
"series_type": "Dizi Türü",
"release_dates": "Yayın Tarihleri",
"first_air_date": "İlk Yayın Tarihi",
"next_air_date": "Sonraki Yayın Tarihi",
"revenue": "Gelir",
"budget": "Bütçe",
"original_language": "Orijinal Dil",
"production_country": "Yapım Ülkesi",
"studios": "Stüdyolar",
"network": "Ağ",
"currently_streaming_on": "Şu anda yayınlanıyor",
"advanced": "Gelişmiş",
"request_as": "Şu olarak iste",
"tags": "Etiketler",
"quality_profile": "Kalite Profili",
"root_folder": "Kök Klasör",
"season_x": "Sezon {{seasons}}",
"season_number": "Sezon {{season_number}}",
"number_episodes": "Bölüm {{episode_number}}",
"born": "Doğum",
"appearances": "Görünmeler",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin",
"jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.",
"failed_to_test_jellyseerr_server_url": "Jellyseerr sunucu URL'si test edilemedi",
"issue_submitted": "Sorun gönderildi!",
"requested_item": "{{item}} talep edildi!",
"you_dont_have_permission_to_request": "İstek göndermeye izniniz yok!",
"something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!"
}
},
"tabs": {
"home": "Ana Sayfa",
"search": "Ara",
"library": "Kütüphane",
"custom_links": "Özel Bağlantılar",
"favorites": "Favoriler"
}
}

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,134 +0,0 @@
import { TranscodedSubtitle } from "@/components/video-player/controls/types";
import { TrackInfo } from "@/modules/vlc-player";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
const disableSubtitle = {
name: "Disable",
index: -1,
IsTextSubtitleStream: true,
} as TranscodedSubtitle;
export class SubtitleHelper {
private mediaStreams: MediaStream[];
constructor(mediaStreams: MediaStream[]) {
this.mediaStreams = mediaStreams.filter((x) => x.Type === "Subtitle");
}
getSubtitles(): MediaStream[] {
return this.mediaStreams;
}
getUniqueSubtitles(): MediaStream[] {
const uniqueSubs: MediaStream[] = [];
const seen = new Set<string>();
this.mediaStreams.forEach((x) => {
if (!seen.has(x.DisplayTitle!)) {
seen.add(x.DisplayTitle!);
uniqueSubs.push(x);
}
});
return uniqueSubs;
}
getCurrentSubtitle(subtitleIndex?: number): MediaStream | undefined {
return this.mediaStreams.find((x) => x.Index === subtitleIndex);
}
getMostCommonSubtitleByName(
subtitleIndex: number | undefined
): number | undefined {
if (subtitleIndex === undefined) -1;
const uniqueSubs = this.getUniqueSubtitles();
const currentSub = this.getCurrentSubtitle(subtitleIndex);
return uniqueSubs.find((x) => x.DisplayTitle === currentSub?.DisplayTitle)
?.Index;
}
getTextSubtitles(): MediaStream[] {
return this.mediaStreams.filter((x) => x.IsTextSubtitleStream);
}
getImageSubtitles(): MediaStream[] {
return this.mediaStreams.filter((x) => !x.IsTextSubtitleStream);
}
getEmbeddedTrackIndex(sourceSubtitleIndex: number): number {
if (Platform.OS === "android") {
const textSubs = this.getTextSubtitles();
const matchingSubtitle = textSubs.find(
(sub) => sub.Index === sourceSubtitleIndex
);
if (!matchingSubtitle) return -1;
return textSubs.indexOf(matchingSubtitle);
}
// Get unique text-based subtitles because react-native-video removes hls text tracks duplicates. (iOS)
const uniqueTextSubs = this.getUniqueTextBasedSubtitles();
const matchingSubtitle = uniqueTextSubs.find(
(sub) => sub.Index === sourceSubtitleIndex
);
if (!matchingSubtitle) return -1;
return uniqueTextSubs.indexOf(matchingSubtitle);
}
sortSubtitles(
textSubs: TranscodedSubtitle[],
allSubs: MediaStream[]
): TranscodedSubtitle[] {
let textIndex = 0; // To track position in textSubtitles
// Merge text and image subtitles in the order of allSubs
const sortedSubtitles = allSubs.map((sub) => {
if (sub.IsTextSubtitleStream) {
if (textSubs.length === 0) return disableSubtitle;
const textSubtitle = textSubs[textIndex];
if (!textSubtitle) return disableSubtitle;
textIndex++;
return textSubtitle;
} else {
return {
name: sub.DisplayTitle!,
index: sub.Index!,
IsTextSubtitleStream: sub.IsTextSubtitleStream,
} as TranscodedSubtitle;
}
});
return sortedSubtitles;
}
getSortedSubtitles(subtitleTracks: TrackInfo[]): TranscodedSubtitle[] {
const textSubtitles =
subtitleTracks.map((s) => ({
name: s.name,
index: s.index,
IsTextSubtitleStream: true,
})) || [];
const sortedSubs =
Platform.OS === "android"
? this.sortSubtitles(textSubtitles, this.mediaStreams)
: this.sortSubtitles(textSubtitles, this.getUniqueSubtitles());
return sortedSubs;
}
getUniqueTextBasedSubtitles(): MediaStream[] {
return this.getUniqueSubtitles().filter((x) => x.IsTextSubtitleStream);
}
// HLS stream indexes are not the same as the actual source indexes.
// This function aims to get the source subtitle index from the embedded track index.
getSourceSubtitleIndex = (embeddedTrackIndex: number): number => {
if (Platform.OS === "android") {
return this.getTextSubtitles()[embeddedTrackIndex]?.Index ?? -1;
}
return this.getUniqueTextBasedSubtitles()[embeddedTrackIndex]?.Index ?? -1;
};
}

Some files were not shown because too many files have changed in this diff Show More