mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 05:28:37 +01:00
Compare commits
3 Commits
v0.26.1
...
chore/expo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09189e125e | ||
|
|
1ac10d8f34 | ||
|
|
d5fe354986 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,8 +43,6 @@ 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
|
||||||
|
|||||||
39
.github/workflows/main.yml
vendored
39
.github/workflows/main.yml
vendored
@@ -1,39 +0,0 @@
|
|||||||
name: Handle Stale Issues
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/stale@v9
|
|
||||||
with:
|
|
||||||
# Issue specific settings
|
|
||||||
days-before-issue-stale: 90
|
|
||||||
days-before-issue-close: 7
|
|
||||||
stale-issue-label: "stale"
|
|
||||||
stale-issue-message: |
|
|
||||||
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
|
|
||||||
|
|
||||||
If this issue is still relevant, please leave a comment to keep it open.
|
|
||||||
Otherwise, it will be closed in 7 days if no further activity occurs.
|
|
||||||
|
|
||||||
Thank you for your contributions!
|
|
||||||
close-issue-message: |
|
|
||||||
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
|
|
||||||
|
|
||||||
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
|
|
||||||
|
|
||||||
# Pull request settings (disabled)
|
|
||||||
days-before-pr-stale: -1
|
|
||||||
days-before-pr-close: -1
|
|
||||||
|
|
||||||
# Other settings
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
operations-per-run: 100
|
|
||||||
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,4 +42,3 @@ credentials.json
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
modules/hls-downloader/android/build
|
|
||||||
87
README.md
87
README.md
@@ -18,7 +18,6 @@ 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
|
||||||
@@ -68,7 +67,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
|
|||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -85,9 +84,9 @@ 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. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have xcode and/or android studio installed.
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
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`.
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
@@ -123,85 +122,7 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
|
|||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
### Core Developers
|
I'd like to thank the following people and projects for their contributions to Streamyfin:
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
47
app.json
47
app.json
@@ -2,19 +2,28 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.26.1",
|
"version": "0.25.0",
|
||||||
"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"
|
||||||
|
},
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio", "fetch"],
|
"UIBackgroundModes": [
|
||||||
|
"audio",
|
||||||
|
"fetch"
|
||||||
|
],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
@@ -31,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 53,
|
"versionCode": 50,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -68,10 +77,11 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"compileSdkVersion": 35,
|
"android": {
|
||||||
"targetSdkVersion": 35,
|
"compileSdkVersion": 34,
|
||||||
"buildToolsVersion": "35.0.0",
|
"targetSdkVersion": 34,
|
||||||
"kotlinVersion": "2.0.21",
|
"buildToolsVersion": "34.0.0"
|
||||||
|
},
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 24,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
@@ -106,18 +116,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"react-native-bottom-tabs"
|
||||||
{
|
],
|
||||||
"backgroundColor": "#2e2e2e",
|
[
|
||||||
"image": "./assets/images/StreamyFinFinal.png",
|
"./plugins/withChangeNativeAndroidTextToWhite.js"
|
||||||
"imageWidth": 100
|
],
|
||||||
}
|
[
|
||||||
|
"./plugins/withGoogleCastActivity.js"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withTrustLocalCerts.js"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ 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;
|
import { lazy } from "react";
|
||||||
|
// const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||||
|
const Chromecast = lazy(() => import("@/components/Chromecast"));
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -26,7 +28,7 @@ export default function IndexLayout() {
|
|||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast />
|
<Chromecast />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
|
|||||||
@@ -1,5 +1,498 @@
|
|||||||
import { SettingsIndex } from "@/components/settings/SettingsIndex";
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import {
|
||||||
|
useSplashScreenLoading,
|
||||||
|
useSplashScreenVisible,
|
||||||
|
} from "@/providers/SplashScreenProvider";
|
||||||
|
|
||||||
export default function page() {
|
type ScrollingCollectionListSection = {
|
||||||
return <SettingsIndex />;
|
type: "ScrollingCollectionList";
|
||||||
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaListSection = {
|
||||||
|
type: "MediaListSection";
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
|
|
||||||
|
export default function index() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
setPluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
] = useSettings();
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||||
|
useEffect(() => {
|
||||||
|
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/downloads");
|
||||||
|
}}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name="download"
|
||||||
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [downloadedFiles, navigation, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanCacheDirectory().catch((e) =>
|
||||||
|
console.error("Something went wrong cleaning cache directory")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkConnection = useCallback(async () => {
|
||||||
|
setLoadingRetry(true);
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
setLoadingRetry(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
|
if (state.isConnected == false || state.isInternetReachable === false)
|
||||||
|
setIsConnected(false);
|
||||||
|
else setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
NetInfo.fetch().then((state) => {
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanCacheDirectory().catch((e) =>
|
||||||
|
// console.error("Something went wrong cleaning cache directory")
|
||||||
|
// );
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// show splash screen until query loaded
|
||||||
|
useSplashScreenLoading(l1);
|
||||||
|
const splashScreenVisible = useSplashScreenVisible();
|
||||||
|
|
||||||
|
const userViews = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = useMemo(() => {
|
||||||
|
const allow = ["movies", "tvshows"];
|
||||||
|
return (
|
||||||
|
userViews?.filter(
|
||||||
|
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined
|
||||||
|
): ScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 20,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || []
|
||||||
|
);
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
}),
|
||||||
|
[api, user?.Id]
|
||||||
|
);
|
||||||
|
|
||||||
|
let sections: Section[] = [];
|
||||||
|
if (!settings?.home || !settings?.home?.sections) {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
|
const queryKey = [
|
||||||
|
"home",
|
||||||
|
"recentlyAddedIn" + c.CollectionType,
|
||||||
|
user?.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
...latestMediaViews,
|
||||||
|
// ...(mediaListCollections?.map(
|
||||||
|
// (ml) =>
|
||||||
|
// ({
|
||||||
|
// title: ml.Name,
|
||||||
|
// queryKey: ["home", "mediaList", ml.Id!],
|
||||||
|
// queryFn: async () => ml,
|
||||||
|
// type: "MediaListSection",
|
||||||
|
// orientation: "vertical",
|
||||||
|
// } as Section)
|
||||||
|
// ) || []),
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.suggested_episodes"),
|
||||||
|
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const suggestions = await getSuggestions(api, user.Id);
|
||||||
|
const nextUpPromises = suggestions.map((series) =>
|
||||||
|
getNextUp(api, user.Id, series.Id)
|
||||||
|
);
|
||||||
|
const nextUpResults = await Promise.all(nextUpPromises);
|
||||||
|
|
||||||
|
return nextUpResults.filter((item) => item !== null) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, collections]);
|
||||||
|
} else {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
|
||||||
|
for (const key in settings.home?.sections) {
|
||||||
|
// @ts-expect-error
|
||||||
|
const section = settings.home?.sections[key];
|
||||||
|
const id = section.title || key;
|
||||||
|
ss.push({
|
||||||
|
title: id,
|
||||||
|
queryKey: ["home", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
|
sortBy: section.items?.sortBy,
|
||||||
|
sortOrder: section.items?.sortOrder,
|
||||||
|
filters: section.items?.filters,
|
||||||
|
parentId: section.items?.parentId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
} else if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: section.items?.enableResumable || false,
|
||||||
|
enableRewatching: section.items?.enableRewatching || false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings.home?.sections]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnected === false) {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||||
|
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
||||||
|
<Text className="text-center opacity-70">
|
||||||
|
{t("home.no_internet_message")}
|
||||||
|
</Text>
|
||||||
|
<View className="mt-4">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onPress={() => router.push("/(auth)/downloads")}
|
||||||
|
justify="center"
|
||||||
|
iconRight={
|
||||||
|
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.go_to_downloads")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="black"
|
||||||
|
onPress={() => {
|
||||||
|
checkConnection();
|
||||||
|
}}
|
||||||
|
justify="center"
|
||||||
|
className="mt-2"
|
||||||
|
iconRight={
|
||||||
|
loadingRetry ? null : (
|
||||||
|
<Ionicons name="refresh" size={20} color="white" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingRetry ? (
|
||||||
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
) : (
|
||||||
|
"Retry"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e1)
|
||||||
|
return (
|
||||||
|
<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-center opacity-70">
|
||||||
|
{t("home.error_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// this spinner should only show up, when user navigates here
|
||||||
|
// on launch the splash screen is used for loading
|
||||||
|
if (l1 && !splashScreenVisible)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col space-y-4">
|
||||||
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "ScrollingCollectionList") {
|
||||||
|
return (
|
||||||
|
<ScrollingCollectionList
|
||||||
|
key={index}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (section.type === "MediaListSection") {
|
||||||
|
return (
|
||||||
|
<MediaListSection
|
||||||
|
key={index}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get suggestions
|
||||||
|
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||||
|
if (!userId) return [];
|
||||||
|
const response = await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Unknown"],
|
||||||
|
type: ["Series"],
|
||||||
|
});
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get the next up TV show for a series
|
||||||
|
async function getNextUp(
|
||||||
|
api: Api,
|
||||||
|
userId: string | undefined,
|
||||||
|
seriesId: string | undefined
|
||||||
|
) {
|
||||||
|
if (!userId || !seriesId) return null;
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId,
|
||||||
|
seriesId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return response.data.Items?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +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 { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
|
||||||
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";
|
||||||
@@ -11,16 +10,20 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
|
|||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
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";
|
||||||
|
const DownloadSettings = lazy(
|
||||||
|
() => import("@/components/settings/DownloadSettings")
|
||||||
|
);
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -69,7 +72,7 @@ export default function settings() {
|
|||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
<DownloadSettings />
|
{!Platform.isTV && <DownloadSettings />}
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function page() {
|
|||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
@@ -32,6 +32,15 @@ export default function page() {
|
|||||||
enabled: !!jellyseerrApi && !!personId,
|
enabled: !!jellyseerrApi && !!personId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const locale = useMemo(() => {
|
||||||
|
return jellyseerrUser?.settings?.locale || "en";
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const region = useMemo(
|
||||||
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
|
[jellyseerrUser]
|
||||||
|
);
|
||||||
|
|
||||||
const castedRoles: PersonCreditCast[] = useMemo(
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
uniqBy(orderBy(
|
uniqBy(orderBy(
|
||||||
|
|||||||
@@ -153,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: string) => {
|
onValueChange={(newValue) => {
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
return;
|
return;
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -172,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: string) => {
|
onValueChange={(newValue) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
|
|||||||
@@ -38,18 +38,9 @@ export default function SearchLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
||||||
name="jellyseerr/person/[personId]"
|
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
|
||||||
options={commonScreenOptions}
|
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="jellyseerr/company/[companyId]"
|
|
||||||
options={commonScreenOptions}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="jellyseerr/genre/[genreId]"
|
|
||||||
options={commonScreenOptions}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function search() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { q } = params as { q: string };
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
@@ -122,17 +122,18 @@ export default function search() {
|
|||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
if (Platform.OS === "ios")
|
||||||
headerSearchBarOptions: {
|
navigation.setOptions({
|
||||||
placeholder: t("search.search"),
|
headerSearchBarOptions: {
|
||||||
onChangeText: (e: any) => {
|
placeholder: t("search.search"),
|
||||||
router.setParams({ q: "" });
|
onChangeText: (e: any) => {
|
||||||
setSearch(e.nativeEvent.text);
|
router.setParams({ q: "" });
|
||||||
|
setSearch(e.nativeEvent.text);
|
||||||
|
},
|
||||||
|
hideWhenScrolling: false,
|
||||||
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
});
|
||||||
autoFocus: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const { data: movies, isFetching: l1 } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
@@ -209,12 +210,19 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View className="flex flex-col">
|
||||||
className="flex flex-col"
|
{Platform.OS === "android" && (
|
||||||
style={{
|
<View className="mb-4 px-4">
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
<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")}>
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ export default function TabLayout() {
|
|||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
ignoresTopSafeArea
|
||||||
tabBarStyle={{
|
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||||
backgroundColor: "#121212",
|
|
||||||
}}
|
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
scrollEdgeAppearance="default"
|
scrollEdgeAppearance="default"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,28 +1,8 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React, { useEffect } from "react";
|
import React 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 />
|
||||||
@@ -36,6 +16,15 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="transcoding-player"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ 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,
|
||||||
@@ -17,18 +18,20 @@ const downloadProvider = !Platform.isTV
|
|||||||
? require("@/providers/DownloadProvider")
|
? require("@/providers/DownloadProvider")
|
||||||
: null;
|
: 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 { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
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 { useGlobalSearchParams, useNavigation } from "expo-router";
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -37,20 +40,25 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
|
import {
|
||||||
|
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);
|
||||||
@@ -58,14 +66,13 @@ 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) {
|
if (!Platform.isTV) {
|
||||||
getDownloadedItem = downloadProvider.useDownload();
|
const getDownloadedItem = downloadProvider.useDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
@@ -109,7 +116,7 @@ export default function page() {
|
|||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const item = await getDownloadedItem.getDownloadedItem(itemId);
|
const item = await getDownloadedItem(itemId);
|
||||||
if (item) return item.item;
|
if (item) return item.item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,80 +131,57 @@ export default function page() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [stream, setStream] = useState<{
|
const {
|
||||||
mediaSource: MediaSourceInfo;
|
data: stream,
|
||||||
url: string;
|
isLoading: isLoadingStreamUrl,
|
||||||
sessionId: string | undefined;
|
isError: isErrorStreamUrl,
|
||||||
} | null>(null);
|
} = useQuery({
|
||||||
const [isLoadingStream, setIsLoadingStream] = useState(true);
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
const [isErrorStream, setIsErrorStream] = useState(false);
|
queryFn: async () => {
|
||||||
|
if (offline && !Platform.isTV) {
|
||||||
|
const data = await getDownloadedItem(itemId);
|
||||||
|
if (!data?.mediaSource) return null;
|
||||||
|
|
||||||
useEffect(() => {
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
const fetchStream = async () => {
|
|
||||||
setIsLoadingStream(true);
|
|
||||||
setIsErrorStream(false);
|
|
||||||
|
|
||||||
try {
|
if (item)
|
||||||
if (offline && !Platform.isTV) {
|
return {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
mediaSource: data.mediaSource,
|
||||||
if (!data?.mediaSource) {
|
url,
|
||||||
setStream(null);
|
sessionId: undefined,
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
|
||||||
|
|
||||||
if (item) {
|
|
||||||
setStream({
|
|
||||||
mediaSource: data.mediaSource as MediaSourceInfo,
|
|
||||||
url,
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
|
||||||
setStream(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
|
||||||
setStream(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStream({
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching stream:", error);
|
|
||||||
setIsErrorStream(true);
|
|
||||||
setStream(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingStream(false);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
fetchStream();
|
const res = await getStreamUrl({
|
||||||
}, [itemId, mediaSourceId]);
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: bitrateValue,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: native,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) return null;
|
||||||
|
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
|
||||||
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSource,
|
||||||
|
sessionId,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!itemId && !!item,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -205,21 +189,37 @@ export default function page() {
|
|||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
|
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: true,
|
||||||
|
playMethod: stream.url?.includes("m3u8")
|
||||||
|
? "Transcode"
|
||||||
|
: "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
}
|
if (!offline && stream) {
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
if (!offline && stream) {
|
itemId: item?.Id!,
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
itemId: item?.Id!,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
positionTicks: msToTicks(progress.value),
|
||||||
mediaSourceId: mediaSourceId,
|
isPaused: false,
|
||||||
positionTicks: msToTicks(progress.get()),
|
playMethod: stream?.url.includes("m3u8")
|
||||||
isPaused: !isPlaying,
|
? "Transcode"
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
: "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@@ -231,13 +231,13 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
offline,
|
offline,
|
||||||
progress,
|
progress.value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.value);
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -255,9 +255,25 @@ 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.get() || isPlaybackStopped) return;
|
if (isSeeking.value === true) return;
|
||||||
|
if (isPlaybackStopped === true) return;
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { currentTime } = data.nativeEvent;
|
||||||
|
|
||||||
@@ -265,7 +281,7 @@ export default function page() {
|
|||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.set(currentTime);
|
progress.value = currentTime;
|
||||||
|
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
@@ -284,9 +300,12 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay: togglePlay,
|
||||||
@@ -294,23 +313,16 @@ export default function page() {
|
|||||||
offline,
|
offline,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
const { pipStarted } = e.nativeEvent;
|
|
||||||
setIsPipStarted(pipStarted);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => {
|
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
|
||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync()
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (state === "Paused") {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,71 +342,96 @@ export default function page() {
|
|||||||
: 0;
|
: 0;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
// Preselection of audio and subtitle tracks.
|
useFocusEffect(
|
||||||
if (!settings) return null;
|
React.useCallback(() => {
|
||||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
return async () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [appState, setAppState] = useState(AppState.currentState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||||
|
// Handle app coming to the foreground
|
||||||
|
} else if (nextAppState.match(/inactive|background/)) {
|
||||||
|
// Handle app going to the background
|
||||||
|
if (videoRef.current && videoRef.current.pause) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAppState(nextAppState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use AppState.addEventListener and return a cleanup function
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup the event listener when the component is unmounted
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [appState]);
|
||||||
|
|
||||||
|
// Preselection of audio and subtitle tracks.
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
|
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||||
|
|
||||||
const allAudio =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(audio) => audio.Type === "Audio"
|
|
||||||
) || [];
|
|
||||||
const allSubs =
|
const allSubs =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(sub) => sub.Type === "Subtitle"
|
(sub) => sub.Type === "Subtitle"
|
||||||
) || [];
|
) || [];
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
|
||||||
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
(sub) => sub.Index === subtitleIndex
|
(sub) => sub.Index === subtitleIndex
|
||||||
);
|
);
|
||||||
|
const allAudio =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
|
(audio) => audio.Type === "Audio"
|
||||||
|
) || [];
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
// Direct playback CASE
|
||||||
if (
|
if (!bitrateValue) {
|
||||||
chosenSubtitleTrack &&
|
// If Subtitle is embedded we can use the position to select it straight away.
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
||||||
) {
|
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
||||||
const finalIndex = notTranscoding
|
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
// If Subtitle is external we need to pass the URL to the player.
|
||||||
: textSubs.indexOf(chosenSubtitleTrack);
|
externalTrack = {
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
name: chosenSubtitleTrack.DisplayTitle || "",
|
||||||
|
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chosenAudioTrack)
|
||||||
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
|
} else {
|
||||||
|
// Transcoded playback CASE
|
||||||
|
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||||
|
externalTrack = {
|
||||||
|
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
||||||
|
DeliveryUrl: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const externalSubtitles = allSubs
|
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
|
||||||
.map((sub: any) => ({
|
|
||||||
name: sub.DisplayTitle,
|
|
||||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
return () => setIsMounted(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
useEffect(() => {
|
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
|
||||||
return () => {
|
|
||||||
beforeRemoveListener();
|
|
||||||
};
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
if (!item || isLoadingItem || !stream)
|
if (!item || isLoadingItem || isLoadingStreamUrl || !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 || isErrorStream)
|
if (isErrorItem || isErrorStreamUrl)
|
||||||
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>
|
||||||
@@ -418,18 +455,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,
|
||||||
externalSubtitles,
|
externalTrack,
|
||||||
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={() => {}}
|
||||||
onVideoLoadEnd={() => {
|
onVideoLoadEnd={() => {
|
||||||
setIsVideoLoaded(true);
|
setIsVideoLoaded(true);
|
||||||
}}
|
}}
|
||||||
@@ -443,7 +480,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true ? (
|
{videoRef.current && (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -459,7 +496,6 @@ 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}
|
||||||
@@ -473,7 +509,26 @@ export default function page() {
|
|||||||
stop={stop}
|
stop={stop}
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
</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;
|
||||||
|
}
|
||||||
|
|||||||
547
app/(auth)/player/transcoding-player.tsx
Normal file
547
app/(auth)/player/transcoding-player.tsx
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +10,10 @@ 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 { 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";
|
||||||
@@ -27,15 +32,16 @@ const BackgroundFetch = !Platform.isTV
|
|||||||
? require("expo-background-fetch")
|
? require("expo-background-fetch")
|
||||||
: null;
|
: null;
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { useFonts } from "expo-font";
|
||||||
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import { router, Stack } from "expo-router";
|
import { router, Stack } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider, useTranslation } from "react-i18next";
|
||||||
import { Appearance, AppState } from "react-native";
|
import { Appearance, AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
@@ -52,39 +58,28 @@ if (!Platform.isTV) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the splash screen visible while we fetch resources
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
|
||||||
|
|
||||||
// Set the animation options. This is optional.
|
|
||||||
SplashScreen.setOptions({
|
|
||||||
duration: 500,
|
|
||||||
fade: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
function redirect(notification: typeof Notifications.Notification) {
|
function redirect(notification: 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(
|
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
(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: { notification: any }) => {
|
(response) => {
|
||||||
redirect(response.notification);
|
redirect(response.notification);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -132,7 +127,7 @@ if (!Platform.isTV) {
|
|||||||
const downloadUrl = url + "download/" + job.id;
|
const downloadUrl = url + "download/" + job.id;
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
if (tasks.find((task) => task.id === job.id)) {
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -168,9 +163,9 @@ if (!Platform.isTV) {
|
|||||||
trigger: null,
|
trigger: null,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.error((error: any) => {
|
.error((error) => {
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
completeHandler(job.id);
|
||||||
Notifications.scheduleNotificationAsync({
|
Notifications.scheduleNotificationAsync({
|
||||||
content: {
|
content: {
|
||||||
title: job.item.Name,
|
title: job.item.Name,
|
||||||
@@ -227,15 +222,17 @@ export default function RootLayout() {
|
|||||||
Appearance.setColorScheme("dark");
|
Appearance.setColorScheme("dark");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<SplashScreenProvider>
|
||||||
<JotaiProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<ActionSheetProvider>
|
<JotaiProvider>
|
||||||
<I18nextProvider i18n={i18n}>
|
<ActionSheetProvider>
|
||||||
<Layout />
|
<I18nextProvider i18n={i18n}>
|
||||||
</I18nextProvider>
|
<Layout />
|
||||||
</ActionSheetProvider>
|
</I18nextProvider>
|
||||||
</JotaiProvider>
|
</ActionSheetProvider>
|
||||||
</GestureHandlerRootView>
|
</JotaiProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</SplashScreenProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,22 +259,22 @@ function Layout() {
|
|||||||
}, [settings?.preferedLanguage, i18n]);
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
|
useKeepAwake();
|
||||||
useNotificationObserver();
|
useNotificationObserver();
|
||||||
|
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAndRequestPermissions();
|
checkAndRequestPermissions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If the user has auto rotate enabled, unlock the orientation
|
if (settings?.autoRotate === true)
|
||||||
if (settings.autoRotate === true) {
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||||
ScreenOrientation.unlockAsync();
|
else
|
||||||
} 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(() => {
|
||||||
@@ -301,6 +298,16 @@ function Layout() {
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [loaded] = useFonts({
|
||||||
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
|
});
|
||||||
|
|
||||||
|
useSplashScreenLoading(!loaded);
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JobQueueProvider>
|
<JobQueueProvider>
|
||||||
@@ -312,7 +319,7 @@ function Layout() {
|
|||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style="light" hidden={false} />
|
<SystemBars style="light" hidden={false} />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName="(auth)/(tabs)">
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/(tabs)"
|
name="(auth)/(tabs)"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -19,20 +19,17 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Keyboard } from "react-native";
|
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { t } from "i18next";
|
import { t } from 'i18next';
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),});
|
||||||
});
|
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -40,8 +37,6 @@ const Login: React.FC = () => {
|
|||||||
password: _password,
|
password: _password,
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
@@ -52,11 +47,10 @@ const Login: React.FC = () => {
|
|||||||
password: _password,
|
password: _password,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* A way to auto login based on a link
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// we might re-use the checkUrl function here to check the url as well
|
||||||
|
// however, I don't think it should be necessary for now
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
@@ -72,6 +66,7 @@ const Login: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerTitle: serverName,
|
headerTitle: serverName,
|
||||||
@@ -84,17 +79,15 @@ const Login: React.FC = () => {
|
|||||||
className="flex flex-row items-center"
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||||
<Text className="ml-2 text-purple-600">
|
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
|
||||||
{t("login.change_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [serverName, navigation, api?.basePath]);
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
Keyboard.dismiss();
|
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
@@ -105,16 +98,15 @@ const Login: React.FC = () => {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
|
||||||
t("login.connection_failed"),
|
|
||||||
t("login.an_unexpected_error_occured")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
* Checks the availability and validity of a Jellyfin server URL.
|
||||||
*
|
*
|
||||||
@@ -188,21 +180,14 @@ const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(
|
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
|
||||||
t("login.quick_connect"),
|
{
|
||||||
t("login.enter_code_to_login", { code: code }),
|
text: t("login.got_it"),
|
||||||
[
|
},
|
||||||
{
|
]);
|
||||||
text: t("login.got_it"),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert(
|
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
|
||||||
t("login.error_title"),
|
|
||||||
t("login.failed_to_initiate_quick_connect")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,18 +201,16 @@ const Login: React.FC = () => {
|
|||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
<View className="px-4 -mt-20 w-full">
|
<View className="px-4 -mt-20 w-full">
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
<>
|
<>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
<>
|
<>
|
||||||
{t("login.login_to_title") + " "}
|
{t("login.login_to_title") + " "}
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : t("login.login_title")}
|
||||||
t("login.login_title")
|
</>
|
||||||
)}
|
</Text>
|
||||||
</>
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-neutral-400">
|
<Text className="text-xs text-neutral-400">
|
||||||
{api.basePath}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -237,6 +220,7 @@ const Login: React.FC = () => {
|
|||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
|
autoFocus
|
||||||
secureTextEntry={false}
|
secureTextEntry={false}
|
||||||
keyboardType="default"
|
keyboardType="default"
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
@@ -316,9 +300,7 @@ const Login: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
disabled={loadingServerCheck}
|
disabled={loadingServerCheck}
|
||||||
onPress={async () => {
|
onPress={async () => await handleConnect(serverURL)}
|
||||||
await handleConnect(serverURL);
|
|
||||||
}}
|
|
||||||
className="w-full grow"
|
className="w-full grow"
|
||||||
>
|
>
|
||||||
{t("server.connect_button")}
|
{t("server.connect_button")}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface Props extends ViewProps {
|
|||||||
background?: "blur" | "transparent";
|
background?: "blur" | "transparent";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chromecast({
|
export default function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
background = "transparent",
|
background = "transparent",
|
||||||
|
|||||||
@@ -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>(settings?.defaultBitrate ?? {
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
@@ -194,11 +194,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (itemsNotDownloaded.length > 1) {
|
if (itemsNotDownloaded.length > 1) {
|
||||||
const defaults = getDefaultPlaySettings(item, settings!);
|
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||||
mediaSource = defaults.mediaSource;
|
item,
|
||||||
audioIndex = defaults.audioIndex;
|
settings!
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
));
|
||||||
// Keep using the selected bitrate for consistency across all downloads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 {
|
||||||
@@ -26,10 +27,11 @@ import { Image } from "expo-image";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "@/packages/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, { lazy, useEffect, useMemo, useState } from "react";
|
||||||
import { Platform, 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";
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
// const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
const Chromecast = lazy(() => import("./Chromecast"));
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
@@ -87,11 +89,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item && (
|
item && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast.Chromecast
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
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" />
|
||||||
@@ -117,6 +115,37 @@ 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 (
|
||||||
@@ -207,6 +236,7 @@ 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(
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
router.push(`/player/direct-player?${q}`);
|
if (!bitrateValue) {
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/player/transcoding-player?${q}`);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
@@ -115,100 +119,96 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
case 0:
|
case 0:
|
||||||
if (!Platform.isTV) {
|
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);
|
||||||
} else {
|
else {
|
||||||
// Get a new URL with the Chromecast device profile:
|
// Get a new URL with the Chromecast device profile:
|
||||||
try {
|
const data = await getStreamUrl({
|
||||||
const data = await getStreamUrl({
|
api,
|
||||||
api,
|
item,
|
||||||
item,
|
deviceProfile: chromecastProfile,
|
||||||
deviceProfile: chromecastProfile,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
if (!data?.url) {
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.could_not_create_stream_for_chromecast")
|
t("player.could_not_create_stream_for_chromecast")
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
client
|
|
||||||
.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: data?.url,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata:
|
|
||||||
item.Type === "Episode"
|
|
||||||
? {
|
|
||||||
type: "tvShow",
|
|
||||||
title: item.Name || "",
|
|
||||||
episodeNumber: item.IndexNumber || 0,
|
|
||||||
seasonNumber: item.ParentIndexNumber || 0,
|
|
||||||
seriesTitle: item.SeriesName || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getParentBackdropImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? {
|
|
||||||
type: "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "generic",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// state is already set when reopening current media, so skip it here.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CastContext.showExpandedControls();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.loadMedia({
|
||||||
|
mediaInfo: {
|
||||||
|
contentUrl: data?.url,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
metadata:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
type: "tvShow",
|
||||||
|
title: item.Name || "",
|
||||||
|
episodeNumber: item.IndexNumber || 0,
|
||||||
|
seasonNumber: item.ParentIndexNumber || 0,
|
||||||
|
seriesTitle: item.SeriesName || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getParentBackdropImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: item.Type === "Movie"
|
||||||
|
? {
|
||||||
|
type: "movie",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "generic",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: 0,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// state is already set when reopening current media, so skip it here.
|
||||||
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CastContext.showExpandedControls();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
router.push(`/player/direct-player?${q}`);
|
if (!bitrateValue) {
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/player/transcoding-player?${q}`);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,31 +4,40 @@ import { useMemo } from "react";
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
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;
|
if (Platform.isTV) return null;
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||||
}, [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();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const BackGroundDownloader = !Platform.isTV
|
|||||||
: null;
|
: 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";
|
||||||
const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
const FFmpegKit = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -42,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: JobStatus) => (
|
{processes?.map((p) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -80,8 +80,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
FFmpegKit.cancel(Number(id));
|
||||||
setProcesses((prev: any[]) => prev.filter((p: { id: string; }) => p.id !== id));
|
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface Release {
|
|||||||
type: number;
|
type: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dateOpts: Intl.DateTimeFormatOptions = {
|
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -50,9 +50,18 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
|||||||
const DetailFacts: React.FC<
|
const DetailFacts: React.FC<
|
||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
> = ({ details, className, ...props }) => {
|
> = ({ details, className, ...props }) => {
|
||||||
const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
const { jellyseerrUser } = useJellyseerr();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const locale = useMemo(() => {
|
||||||
|
return jellyseerrUser?.settings?.locale || "en";
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const region = useMemo(
|
||||||
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
|
[jellyseerrUser]
|
||||||
|
);
|
||||||
|
|
||||||
const releases = useMemo(
|
const releases = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(details as MovieDetails)?.releases?.results.find(
|
(details as MovieDetails)?.releases?.results.find(
|
||||||
|
|||||||
@@ -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={['transparent', 'transparent']}
|
colors={[]}
|
||||||
contentFit={"cover"}
|
contentFit={"cover"}
|
||||||
url={jellyseerrApi?.imageProxy(
|
url={jellyseerrApi?.imageProxy(
|
||||||
item.backdrops?.[0],
|
item.backdrops?.[0],
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import { Loader } from "../Loader";
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
|
||||||
import {dateOpts} from "@/components/jellyseerr/DetailFacts";
|
|
||||||
|
|
||||||
const JellyseerrSeasonEpisodes: React.FC<{
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
details: TvDetails;
|
details: TvDetails;
|
||||||
@@ -54,51 +52,26 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RenderItem = ({ item, index }: any) => {
|
const RenderItem = ({ item, index }: any) => {
|
||||||
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
const upcomingAirDate = useMemo(() => {
|
|
||||||
const airDate = item.airDate;
|
|
||||||
if (airDate) {
|
|
||||||
let airDateObj = new Date(airDate);
|
|
||||||
|
|
||||||
if (new Date() < airDateObj) {
|
|
||||||
return airDateObj.toLocaleDateString(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
dateOpts
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col w-44 mt-2">
|
<View className="flex flex-col w-44 mt-2">
|
||||||
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
<>
|
<Image
|
||||||
<Image
|
key={item.id}
|
||||||
key={item.id}
|
id={item.id}
|
||||||
id={item.id}
|
source={{
|
||||||
source={{
|
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
}}
|
||||||
}}
|
cachePolicy={"memory-disk"}
|
||||||
cachePolicy={"memory-disk"}
|
contentFit="cover"
|
||||||
contentFit="cover"
|
className="w-full h-full"
|
||||||
className="w-full h-full"
|
onError={(e) => {
|
||||||
onError={(e) => {
|
setImageError(true);
|
||||||
setImageError(true);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
{upcomingAirDate && (
|
|
||||||
<View className="absolute justify-center bottom-0 right-0.5 items-center">
|
|
||||||
<View className="rounded-full bg-purple-600/30 p-1">
|
|
||||||
<Text className="text-center text-xs" style={textShadowStyle.shadow}>
|
|
||||||
{upcomingAirDate}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function DownloadSettings({ ...props }) {
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
{t("home.settings.downloads.download_method")}
|
{t("home.settings.downloads.methods")}
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="1"
|
key="1"
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function DownloadSettings({ ...props }) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,9 @@ export const JellyseerrSettings = () => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
|
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -36,16 +39,11 @@ export const JellyseerrSettings = () => {
|
|||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
|
||||||
throw new Error("Missing server url");
|
|
||||||
if (!user?.Name)
|
|
||||||
throw new Error("Missing required information for login");
|
throw new Error("Missing required information for login");
|
||||||
const jellyseerrTempApi = new JellyseerrApi(
|
}
|
||||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
|
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||||
);
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
|
||||||
const testResult = await jellyseerrTempApi.test();
|
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
|
||||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
|
||||||
},
|
},
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setJellyseerrUser(user);
|
setJellyseerrUser(user);
|
||||||
@@ -59,11 +57,31 @@ export const JellyseerrSettings = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const testJellyseerrServerUrlMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!jellyseerrServerUrl || jellyseerrApi) return null;
|
||||||
|
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||||
|
return jellyseerrTempApi.test();
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result && result.isValid) {
|
||||||
|
if (result.requiresPass) {
|
||||||
|
setPromptForJellyseerrPass(true);
|
||||||
|
} else {
|
||||||
|
updateSettings({ jellyseerrServerUrl });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPromptForJellyseerrPass(false);
|
||||||
|
setjellyseerrServerUrl(undefined);
|
||||||
|
clearAllJellyseerData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const clearData = () => {
|
const clearData = () => {
|
||||||
clearAllJellyseerData().finally(() => {
|
clearAllJellyseerData().finally(() => {
|
||||||
setJellyseerrUser(undefined);
|
|
||||||
setJellyseerrPassword(undefined);
|
|
||||||
setjellyseerrServerUrl(undefined);
|
setjellyseerrServerUrl(undefined);
|
||||||
|
setPromptForJellyseerrPass(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,46 +92,34 @@ export const JellyseerrSettings = () => {
|
|||||||
<>
|
<>
|
||||||
<ListGroup title={"Jellyseerr"}>
|
<ListGroup title={"Jellyseerr"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
|
||||||
"home.settings.plugins.jellyseerr.total_media_requests"
|
|
||||||
)}
|
|
||||||
value={jellyseerrUser?.requestCount?.toString()}
|
value={jellyseerrUser?.requestCount?.toString()}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
|
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
|
||||||
value={
|
value={
|
||||||
jellyseerrUser?.movieQuotaLimit?.toString() ??
|
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
|
||||||
t("home.settings.plugins.jellyseerr.unlimited")
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
|
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
|
||||||
value={
|
value={
|
||||||
jellyseerrUser?.movieQuotaDays?.toString() ??
|
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
|
||||||
t("home.settings.plugins.jellyseerr.unlimited")
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
|
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
|
||||||
value={
|
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
|
||||||
jellyseerrUser?.tvQuotaLimit?.toString() ??
|
|
||||||
t("home.settings.plugins.jellyseerr.unlimited")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
|
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
|
||||||
value={
|
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
|
||||||
jellyseerrUser?.tvQuotaDays?.toString() ??
|
|
||||||
t("home.settings.plugins.jellyseerr.unlimited")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<Button color="red" onPress={clearData}>
|
<Button color="red" onPress={clearData}>
|
||||||
{t(
|
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
|
||||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@@ -122,20 +128,15 @@ export const JellyseerrSettings = () => {
|
|||||||
<Text className="text-xs text-red-600 mb-2">
|
<Text className="text-xs text-red-600 mb-2">
|
||||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="font-bold mb-1">
|
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col shrink mb-2">
|
<View className="flex flex-col shrink mb-2">
|
||||||
<Text className="text-xs text-gray-600">
|
<Text className="text-xs text-gray-600">
|
||||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Input
|
<Input
|
||||||
className="border border-neutral-800 mb-2"
|
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
|
||||||
placeholder={t(
|
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder"
|
|
||||||
)}
|
|
||||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
|
||||||
defaultValue={
|
defaultValue={
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
}
|
}
|
||||||
@@ -144,20 +145,40 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
onChangeText={setjellyseerrServerUrl}
|
onChangeText={setjellyseerrServerUrl}
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
editable={!testJellyseerrServerUrlMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<View>
|
|
||||||
<Text className="font-bold mb-2">
|
<Button
|
||||||
{t("home.settings.plugins.jellyseerr.password")}
|
loading={testJellyseerrServerUrlMutation.isPending}
|
||||||
</Text>
|
disabled={testJellyseerrServerUrlMutation.isPending}
|
||||||
|
color={promptForJellyseerrPass ? "red" : "purple"}
|
||||||
|
className="h-12 mt-2"
|
||||||
|
onPress={() => {
|
||||||
|
if (promptForJellyseerrPass) {
|
||||||
|
clearData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
testJellyseerrServerUrlMutation.mutate();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<View
|
||||||
|
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
|
||||||
|
style={{
|
||||||
|
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
|
||||||
<Input
|
<Input
|
||||||
className="border border-neutral-800"
|
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
focusable={true}
|
focusable={true}
|
||||||
placeholder={t(
|
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
|
||||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
|
||||||
{ username: user?.Name }
|
|
||||||
)}
|
|
||||||
value={jellyseerrPassword}
|
value={jellyseerrPassword}
|
||||||
keyboardType="default"
|
keyboardType="default"
|
||||||
secureTextEntry={true}
|
secureTextEntry={true}
|
||||||
@@ -165,7 +186,10 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
textContentType="password"
|
textContentType="password"
|
||||||
onChangeText={setJellyseerrPassword}
|
onChangeText={setJellyseerrPassword}
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
editable={
|
||||||
|
!loginToJellyseerrMutation.isPending &&
|
||||||
|
promptForJellyseerrPass
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
loading={loginToJellyseerrMutation.isPending}
|
loading={loginToJellyseerrMutation.isPending}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
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,
|
||||||
@@ -164,32 +163,6 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.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.default_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}
|
||||||
|
|||||||
@@ -1,485 +0,0 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
|
||||||
type: "ScrollingCollectionList";
|
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSection = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
|
||||||
|
|
||||||
export const SettingsIndex = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
setPluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
] = useSettings();
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
|
||||||
useEffect(() => {
|
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name="download"
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles, navigation, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cleanCacheDirectory().catch((e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory")
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
|
||||||
setLoadingRetry(true);
|
|
||||||
const state = await NetInfo.fetch();
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
setLoadingRetry(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
// cleanCacheDirectory().catch((e) =>
|
|
||||||
// console.error("Something went wrong cleaning cache directory")
|
|
||||||
// );
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined
|
|
||||||
): ScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 20,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
}),
|
|
||||||
[api, user?.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
let sections: Section[] = [];
|
|
||||||
if (!settings?.home || !settings?.home?.sections) {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
|
||||||
const queryKey = [
|
|
||||||
"home",
|
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
|
||||||
user?.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
// ...(mediaListCollections?.map(
|
|
||||||
// (ml) =>
|
|
||||||
// ({
|
|
||||||
// title: ml.Name,
|
|
||||||
// queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
// queryFn: async () => ml,
|
|
||||||
// type: "MediaListSection",
|
|
||||||
// orientation: "vertical",
|
|
||||||
// } as Section)
|
|
||||||
// ) || []),
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.suggested_episodes"),
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id)
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, collections]);
|
|
||||||
} else {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const ss: Section[] = [];
|
|
||||||
|
|
||||||
for (const key in settings.home?.sections) {
|
|
||||||
// @ts-expect-error
|
|
||||||
const section = settings.home?.sections[key];
|
|
||||||
const id = section.title || key;
|
|
||||||
ss.push({
|
|
||||||
title: id,
|
|
||||||
queryKey: ["home", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (section.items) {
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
|
||||||
sortBy: section.items?.sortBy,
|
|
||||||
sortOrder: section.items?.sortOrder,
|
|
||||||
filters: section.items?.filters,
|
|
||||||
parentId: section.items?.parentId,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
} else if (section.nextUp) {
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: section.items?.enableResumable || false,
|
|
||||||
enableRewatching: section.items?.enableRewatching || false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: section?.orientation || "vertical",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, settings.home?.sections]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnected === false) {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
|
||||||
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
{t("home.no_internet_message")}
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("home.go_to_downloads")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={() => {
|
|
||||||
checkConnection();
|
|
||||||
}}
|
|
||||||
justify="center"
|
|
||||||
className="mt-2"
|
|
||||||
iconRight={
|
|
||||||
loadingRetry ? null : (
|
|
||||||
<Ionicons name="refresh" size={20} color="white" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loadingRetry ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
"Retry"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e1)
|
|
||||||
return (
|
|
||||||
<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-center opacity-70">
|
|
||||||
{t("home.error_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4">
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
if (section.type === "ScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<ScrollingCollectionList
|
|
||||||
key={index}
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<MediaListSection
|
|
||||||
key={index}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
|
||||||
if (!userId) return [];
|
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Unknown"],
|
|
||||||
type: ["Series"],
|
|
||||||
});
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
|
||||||
api: Api,
|
|
||||||
userId: string | undefined,
|
|
||||||
seriesId: string | undefined
|
|
||||||
) {
|
|
||||||
if (!userId || !seriesId) return null;
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return response.data.Items?.[0] ?? null;
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
|
||||||
type: "ScrollingCollectionList";
|
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSection = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
|
||||||
|
|
||||||
export const SettingsIndex = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
setPluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
] = useSettings();
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
|
||||||
setLoadingRetry(true);
|
|
||||||
const state = await NetInfo.fetch();
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
setLoadingRetry(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
// cleanCacheDirectory().catch((e) =>
|
|
||||||
// console.error("Something went wrong cleaning cache directory")
|
|
||||||
// );
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined
|
|
||||||
): ScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 20,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
}),
|
|
||||||
[api, user?.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
let sections: Section[] = [];
|
|
||||||
if (!settings?.home || !settings?.home?.sections) {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
|
||||||
const queryKey = [
|
|
||||||
"home",
|
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
|
||||||
user?.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
// ...(mediaListCollections?.map(
|
|
||||||
// (ml) =>
|
|
||||||
// ({
|
|
||||||
// title: ml.Name,
|
|
||||||
// queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
// queryFn: async () => ml,
|
|
||||||
// type: "MediaListSection",
|
|
||||||
// orientation: "vertical",
|
|
||||||
// } as Section)
|
|
||||||
// ) || []),
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.suggested_episodes"),
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id)
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, collections]);
|
|
||||||
} else {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const ss: Section[] = [];
|
|
||||||
|
|
||||||
for (const key in settings.home?.sections) {
|
|
||||||
// @ts-expect-error
|
|
||||||
const section = settings.home?.sections[key];
|
|
||||||
const id = section.title || key;
|
|
||||||
ss.push({
|
|
||||||
title: id,
|
|
||||||
queryKey: ["home", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (section.items) {
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
|
||||||
sortBy: section.items?.sortBy,
|
|
||||||
sortOrder: section.items?.sortOrder,
|
|
||||||
filters: section.items?.filters,
|
|
||||||
parentId: section.items?.parentId,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
} else if (section.nextUp) {
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: section.items?.enableResumable || false,
|
|
||||||
enableRewatching: section.items?.enableRewatching || false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: section?.orientation || "vertical",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, settings.home?.sections]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnected === false) {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
|
||||||
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
{t("home.no_internet_message")}
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("home.go_to_downloads")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={() => {
|
|
||||||
checkConnection();
|
|
||||||
}}
|
|
||||||
justify="center"
|
|
||||||
className="mt-2"
|
|
||||||
iconRight={
|
|
||||||
loadingRetry ? null : (
|
|
||||||
<Ionicons name="refresh" size={20} color="white" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loadingRetry ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
"Retry"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e1)
|
|
||||||
return (
|
|
||||||
<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-center opacity-70">
|
|
||||||
{t("home.error_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4">
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
if (section.type === "ScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<ScrollingCollectionList
|
|
||||||
key={index}
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<MediaListSection
|
|
||||||
key={index}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
|
||||||
if (!userId) return [];
|
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Unknown"],
|
|
||||||
type: ["Series"],
|
|
||||||
});
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
|
||||||
api: Api,
|
|
||||||
userId: string | undefined,
|
|
||||||
seriesId: string | undefined
|
|
||||||
) {
|
|
||||||
if (!userId || !seriesId) return null;
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return response.data.Items?.[0] ?? null;
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
ticksToMs,
|
ticksToMs,
|
||||||
ticksToSeconds,
|
ticksToSeconds,
|
||||||
} from "@/utils/time";
|
} from "@/utils/time";
|
||||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -35,12 +35,7 @@ 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 {
|
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
|
||||||
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,
|
||||||
@@ -54,7 +49,8 @@ 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 DropdownView from "./dropdown/DropdownView";
|
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
||||||
|
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";
|
||||||
@@ -79,7 +75,6 @@ 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[]);
|
||||||
@@ -96,7 +91,6 @@ 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,
|
||||||
@@ -218,10 +212,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrateValue.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
stop();
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
@@ -253,10 +250,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrateValue.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
stop();
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
@@ -413,10 +413,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrateValue.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
stop();
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in gotoEpisode:", error);
|
console.error("Error in gotoEpisode:", error);
|
||||||
}
|
}
|
||||||
@@ -496,15 +499,6 @@ 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}
|
||||||
@@ -548,25 +542,15 @@ export const Controls: React.FC<Props> = ({
|
|||||||
setSubtitleTrack={setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
>
|
>
|
||||||
<DropdownView showControls={showControls} />
|
{!mediaSource?.TranscodingUrl ? (
|
||||||
|
<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}
|
|
||||||
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<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={() => {
|
||||||
@@ -608,7 +592,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{/* )} */}
|
{/* )} */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onClose}
|
onPress={async () => {
|
||||||
|
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" />
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ 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: Track[] | null;
|
audioTracks: TrackInfo[] | null;
|
||||||
subtitleTracks: Track[] | null;
|
subtitleTracks: TrackInfo[] | 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;
|
||||||
@@ -48,155 +45,30 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
setSubtitleURL,
|
setSubtitleURL,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | 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 (getSubtitleTracks) {
|
if (
|
||||||
const subtitleData = await getSubtitleTracks();
|
getSubtitleTracks &&
|
||||||
|
(subtitleTracks === null || subtitleTracks.length === 0)
|
||||||
let textSubIndex = 0;
|
) {
|
||||||
const subtitles: Track[] = allSubs?.map((sub) => {
|
const subtitles = await getSubtitleTracks();
|
||||||
// Always increment for non-transcoding subtitles
|
console.log("Getting embeded subtitles...", 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 audioData = await getAudioTracks();
|
const audio = await getAudioTracks();
|
||||||
if (!audioData) return;
|
setAudioTracks(audio);
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -1,21 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, Platform } from "react-native";
|
import { View, TouchableOpacity, Platform } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
interface DropdownViewProps {
|
interface DropdownViewDirectProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
offline?: boolean; // used to disable external subs for downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownView: React.FC<DropdownViewProps> = ({
|
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||||
showControls,
|
showControls,
|
||||||
offline = false,
|
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 videoContext = useVideoContext();
|
||||||
const { subtitleTracks, audioTracks } = videoContext;
|
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<{
|
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -52,11 +98,21 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
loop={true}
|
loop={true}
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
{subtitleTracks?.map((sub, idx: number) => (
|
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key={`subtitle-item-${idx}`}
|
key={`subtitle-item-${idx}`}
|
||||||
value={subtitleIndex === sub.index.toString()}
|
value={subtitleIndex === sub.index.toString()}
|
||||||
onValueChange={() => sub.setTrack()}
|
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}`}>
|
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||||
{sub.name}
|
{sub.name}
|
||||||
@@ -80,7 +136,12 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key={`audio-item-${idx}`}
|
key={`audio-item-${idx}`}
|
||||||
value={audioIndex === track.index.toString()}
|
value={audioIndex === track.index.toString()}
|
||||||
onValueChange={() => track.setTrack()}
|
onValueChange={() => {
|
||||||
|
setAudioTrack && setAudioTrack(track.index);
|
||||||
|
router.setParams({
|
||||||
|
audioIndex: track.index.toString(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||||
{track.name}
|
{track.name}
|
||||||
@@ -94,4 +155,4 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DropdownView;
|
export default DropdownViewDirect;
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { View, TouchableOpacity, Platform } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
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;
|
||||||
@@ -13,14 +13,7 @@ type ExternalSubtitle = {
|
|||||||
type TranscodedSubtitle = {
|
type TranscodedSubtitle = {
|
||||||
name: string;
|
name: string;
|
||||||
index: number;
|
index: number;
|
||||||
deliveryUrl: string;
|
|
||||||
IsTextSubtitleStream: boolean;
|
IsTextSubtitleStream: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Track = {
|
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
setTrack: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -32,20 +32,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.26.1",
|
"channel": "0.25.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.26.1",
|
"channel": "0.25.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"channel": "0.26.1",
|
"channel": "0.25.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
15
edge-to-edge-fix.patch
Normal file
15
edge-to-edge-fix.patch
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
--- 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
|
||||||
@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
|
|||||||
(x) => x.Type === "Audio"
|
(x) => x.Type === "Audio"
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
// 4. Get default bitrate from settings or fallback to max
|
// 4. Get default bitrate
|
||||||
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
const bitrate = BITRATES[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultAudioIndex:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createHapticHandler = useCallback(
|
const createHapticHandler = useCallback(
|
||||||
(type: typeof Haptics.ImpactFeedbackStyle) => {
|
(type: Haptics.ImpactFeedbackStyle) => {
|
||||||
return Platform.OS === "web" || Platform.isTV
|
return Platform.OS === "web" || Platform.isTV
|
||||||
? () => {}
|
? () => {}
|
||||||
: () => Haptics.impactAsync(type);
|
: () => Haptics.impactAsync(type);
|
||||||
@@ -28,7 +28,7 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const createNotificationFeedback = useCallback(
|
const createNotificationFeedback = useCallback(
|
||||||
(type: typeof Haptics.NotificationFeedbackType) => {
|
(type: Haptics.NotificationFeedbackType) => {
|
||||||
return Platform.OS === "web" || Platform.isTV
|
return Platform.OS === "web" || Platform.isTV
|
||||||
? () => {}
|
? () => {}
|
||||||
: () => Haptics.notificationAsync(type);
|
: () => Haptics.notificationAsync(type);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const useImageColors = ({
|
|||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: false,
|
cache: false,
|
||||||
})
|
})
|
||||||
.then((colors: { platform: string; dominant: string; vibrant: string; detail: string; primary: string; }) => {
|
.then((colors) => {
|
||||||
let primary: string = "#fff";
|
let primary: string = "#fff";
|
||||||
let text: string = "#000";
|
let text: string = "#000";
|
||||||
let backup: string = "#fff";
|
let backup: string = "#fff";
|
||||||
@@ -104,7 +104,7 @@ export const useImageColors = ({
|
|||||||
storage.set(`${source.uri}-text`, text);
|
storage.set(`${source.uri}-text`, text);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: any) => {
|
.catch((error) => {
|
||||||
console.error("Error getting colors", error);
|
console.error("Error getting colors", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,23 +449,12 @@ export const useJellyseerr = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const jellyseerrRegion = useMemo(
|
|
||||||
() => jellyseerrUser?.settings?.region || "US",
|
|
||||||
[jellyseerrUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
const jellyseerrLocale = useMemo(() => {
|
|
||||||
return jellyseerrUser?.settings?.locale || "en";
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrUser,
|
jellyseerrUser,
|
||||||
setJellyseerrUser,
|
setJellyseerrUser,
|
||||||
clearAllJellyseerData,
|
clearAllJellyseerData,
|
||||||
isJellyseerrResult,
|
isJellyseerrResult,
|
||||||
jellyseerrRegion,
|
|
||||||
jellyseerrLocale,
|
|
||||||
requestMedia,
|
requestMedia,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ 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;
|
const FFmpegKit = !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";
|
||||||
@@ -23,9 +22,6 @@ import { JobStatus } from "@/utils/optimize-server";
|
|||||||
import { Platform } from "react-native";
|
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
|
||||||
@@ -100,8 +96,8 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
toast.success(t("home.downloads.toasts.download_completed"));
|
toast.success(t("home.downloads.toasts.download_completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcesses((prev: any[]) => {
|
setProcesses((prev) => {
|
||||||
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -125,8 +121,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: any[]) => {
|
setProcesses((prev) => {
|
||||||
return prev.map((process: { itemId: string | undefined; }) => {
|
return prev.map((process) => {
|
||||||
if (process.itemId === item.Id) {
|
if (process.itemId === item.Id) {
|
||||||
return {
|
return {
|
||||||
...process,
|
...process,
|
||||||
@@ -185,13 +181,13 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||||
setProcesses((prev: any) => [...prev, job]);
|
setProcesses((prev) => [...prev, job]);
|
||||||
|
|
||||||
await FFmpegKit.executeAsync(
|
await FFmpegKit.executeAsync(
|
||||||
createFFmpegCommand(url, output).join(" "),
|
createFFmpegCommand(url, output).join(" "),
|
||||||
(session: any) => completeCallback(session, item),
|
(session) => completeCallback(session, item),
|
||||||
undefined,
|
undefined,
|
||||||
(s: any) => statisticsCallback(s, item)
|
(s) => statisticsCallback(s, item)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as Error;
|
const error = e as Error;
|
||||||
@@ -200,8 +196,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: any[]) => {
|
setProcesses((prev) => {
|
||||||
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
return prev.filter((process) => 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
|
||||||
}
|
}
|
||||||
|
|||||||
9
i18n.ts
9
i18n.ts
@@ -5,10 +5,7 @@ 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 es from "./translations/es.json";
|
||||||
import fr from "./translations/fr.json";
|
import fr from "./translations/fr.json";
|
||||||
import nl from "./translations/nl.json";
|
|
||||||
import sv from "./translations/sv.json";
|
import sv from "./translations/sv.json";
|
||||||
import it from "./translations/it.json";
|
|
||||||
import zhTW from './translations/zh-TW.json';
|
|
||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
|
|
||||||
export const APP_LANGUAGES = [
|
export const APP_LANGUAGES = [
|
||||||
@@ -16,10 +13,7 @@ export const APP_LANGUAGES = [
|
|||||||
{ label: "English", value: "en" },
|
{ label: "English", value: "en" },
|
||||||
{ label: "Español", value: "es" },
|
{ label: "Español", value: "es" },
|
||||||
{ label: "Français", value: "fr" },
|
{ label: "Français", value: "fr" },
|
||||||
{ label: "Nederlands", value: "nl" },
|
|
||||||
{ label: "Svenska", value: "sv" },
|
{ label: "Svenska", value: "sv" },
|
||||||
{ label: "Italiano", value: "it" },
|
|
||||||
{ label: "繁體中文", value: "zh-TW" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
@@ -29,10 +23,7 @@ i18n.use(initReactI18next).init({
|
|||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
es: { translation: es },
|
es: { translation: es },
|
||||||
fr: { translation: fr },
|
fr: { translation: fr },
|
||||||
nl: { translation: nl },
|
|
||||||
sv: { translation: sv },
|
sv: { translation: sv },
|
||||||
it: { translation: it },
|
|
||||||
"zh-TW": { translation: zhTW },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
lng: getLocales()[0].languageCode || "en",
|
lng: getLocales()[0].languageCode || "en",
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
plugins {
|
apply plugin: 'com.android.library'
|
||||||
id 'com.android.library'
|
apply plugin: 'kotlin-android'
|
||||||
id 'kotlin-android'
|
apply plugin: 'kotlin-kapt'
|
||||||
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()
|
||||||
@@ -42,8 +37,8 @@ if (useManagedAndroidSdkVersions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
|
||||||
|
|
||||||
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
|
|
||||||
// https://docs.expo.dev/modules/android-lifecycle-listeners/
|
|
||||||
object VLCManager: ReactActivityLifecycleListener {
|
|
||||||
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
|
|
||||||
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onCreate(activity, savedInstanceState)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onResume(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onResume(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onPause(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onPause(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onUserLeaveHint(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onUserLeaveHint(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onDestroy(activity: Activity?) {
|
|
||||||
// listeners.forEach {
|
|
||||||
// it.onDestroy(activity)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package expo.modules.vlcplayer
|
package expo.modules.vlcplayer
|
||||||
|
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
@@ -8,18 +7,6 @@ class VlcPlayerModule : Module() {
|
|||||||
override fun definition() = ModuleDefinition {
|
override fun definition() = ModuleDefinition {
|
||||||
Name("VlcPlayer")
|
Name("VlcPlayer")
|
||||||
|
|
||||||
OnActivityEntersForeground {
|
|
||||||
VLCManager.listeners.forEach {
|
|
||||||
it.onResume(appContext.currentActivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OnActivityEntersBackground {
|
|
||||||
VLCManager.listeners.forEach {
|
|
||||||
it.onPause(appContext.currentActivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
View(VlcPlayerView::class) {
|
View(VlcPlayerView::class) {
|
||||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
||||||
view.setSource(source)
|
view.setSource(source)
|
||||||
@@ -39,14 +26,9 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,23 @@
|
|||||||
package expo.modules.vlcplayer
|
package expo.modules.vlcplayer
|
||||||
|
|
||||||
import android.R
|
|
||||||
import android.app.Activity
|
|
||||||
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.Bundle
|
|
||||||
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.View
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.RequiresApi
|
import android.widget.FrameLayout
|
||||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleObserver
|
import androidx.lifecycle.LifecycleObserver
|
||||||
import androidx.lifecycle.OnLifecycleEvent
|
import android.net.Uri
|
||||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
|
||||||
import expo.modules.core.logging.LogHandlers
|
|
||||||
import expo.modules.core.logging.Logger
|
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
||||||
import expo.modules.kotlin.views.ExpoView
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
import org.videolan.libvlc.LibVLC
|
import org.videolan.libvlc.LibVLC
|
||||||
import org.videolan.libvlc.Media
|
import org.videolan.libvlc.Media
|
||||||
import org.videolan.libvlc.MediaPlayer
|
|
||||||
import org.videolan.libvlc.interfaces.IMedia
|
import org.videolan.libvlc.interfaces.IMedia
|
||||||
|
import org.videolan.libvlc.MediaPlayer
|
||||||
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, ReactActivityLifecycleListener {
|
|
||||||
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
|
|
||||||
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
|
||||||
@@ -52,12 +26,10 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var lastReportedState: Int? = null
|
private var lastReportedState: Int? = null
|
||||||
private var lastReportedIsPlaying: Boolean? = null
|
private var lastReportedIsPlaying: Boolean? = null
|
||||||
private var media : Media? = null
|
private var media : Media? = null
|
||||||
private var timeLeft: Long? = null
|
|
||||||
|
|
||||||
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
|
||||||
@@ -72,146 +44,23 @@ 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 remoteActionFilter = IntentFilter()
|
|
||||||
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
|
||||||
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
|
||||||
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
|
||||||
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
PIP_PLAY_PAUSE_ACTION -> {
|
|
||||||
if (isPaused) play() else pause()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
setupPipActions()
|
|
||||||
currentActivity.setPictureInPictureParams(getPipParams()!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
|
|
||||||
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
|
|
||||||
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
|
|
||||||
log.debug("Exiting PiP")
|
|
||||||
timeLeft = mediaPlayer?.time
|
|
||||||
pause()
|
|
||||||
|
|
||||||
// Setting the media after reattaching the view allows for a fast video view render
|
|
||||||
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
|
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
|
||||||
mediaPlayer?.media = media
|
|
||||||
mediaPlayer?.play()
|
|
||||||
timeLeft?.let { mediaPlayer?.time = it }
|
|
||||||
mediaPlayer?.pause()
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPipStarted(mapOf(
|
|
||||||
"pipStarted" to info.isInPictureInPictureMode
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
VLCManager.listeners.add(this)
|
|
||||||
setupView()
|
setupView()
|
||||||
setupPiP()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupView() {
|
private fun setupView() {
|
||||||
log.debug("Setting up view")
|
Log.d("VlcPlayerView", "Setting up view")
|
||||||
setBackgroundColor(android.graphics.Color.WHITE)
|
setBackgroundColor(android.graphics.Color.WHITE)
|
||||||
videoLayout = VLCVideoLayout(context).apply {
|
videoLayout = VLCVideoLayout(context).apply {
|
||||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||||
}
|
}
|
||||||
videoLayout.keepScreenOn = true
|
|
||||||
addView(videoLayout)
|
addView(videoLayout)
|
||||||
log.debug("View setup complete")
|
Log.d("VlcPlayerView", "View setup complete")
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupPiP() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
|
||||||
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
|
||||||
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
currentActivity.registerReceiver(
|
|
||||||
actionReceiver,
|
|
||||||
remoteActionFilter,
|
|
||||||
Context.RECEIVER_NOT_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setupPipActions()
|
|
||||||
currentActivity.apply {
|
|
||||||
setPictureInPictureParams(getPipParams()!!)
|
|
||||||
addOnPictureInPictureModeChangedListener(pipChangeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun setupPipActions() {
|
|
||||||
actions.clear()
|
|
||||||
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(
|
|
||||||
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
|
|
||||||
else Icon.createWithResource(context, R.drawable.ic_media_pause),
|
|
||||||
"Play",
|
|
||||||
"Play Video",
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
if (isPaused) 0 else 1,
|
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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>) {
|
||||||
log.debug("setting source $source")
|
|
||||||
if (hasSource) {
|
if (hasSource) {
|
||||||
log.debug("Source already set. Resuming")
|
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||||
play()
|
play()
|
||||||
return
|
return
|
||||||
@@ -236,12 +85,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||||
mediaPlayer?.setEventListener(this)
|
mediaPlayer?.setEventListener(this)
|
||||||
|
|
||||||
log.debug("Loading network file: $uri")
|
Log.d("VlcPlayerView", "Loading network file: $uri")
|
||||||
media = Media(libVLC, Uri.parse(uri))
|
media = Media(libVLC, Uri.parse(uri))
|
||||||
mediaPlayer?.media = media
|
mediaPlayer?.media = media
|
||||||
|
|
||||||
|
|
||||||
log.debug("Debug: Media options: $mediaOptions")
|
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
|
||||||
// media.addOptions(mediaOptions)
|
// media.addOptions(mediaOptions)
|
||||||
|
|
||||||
// Apply subtitle options
|
// Apply subtitle options
|
||||||
@@ -258,17 +107,11 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
hasSource = true
|
hasSource = true
|
||||||
|
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
log.debug("Playing...")
|
Log.d("VlcPlayerView", "Playing...")
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -308,7 +151,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
fun getAudioTracks(): List<Map<String, Any>>? {
|
||||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
|
||||||
|
println("getAudioTracks")
|
||||||
|
println(mediaPlayer?.getAudioTracks())
|
||||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
||||||
|
|
||||||
return trackDescriptions.map { trackDescription ->
|
return trackDescriptions.map { trackDescription ->
|
||||||
@@ -332,32 +177,19 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Debug statement to print the result
|
// Debug statement to print the result
|
||||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
|
||||||
|
|
||||||
return subtitleTracks
|
return subtitleTracks
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
fun setSubtitleURL(subtitleURL: String, name: String) {
|
||||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
println("Setting subtitle URL: $subtitleURL, name: $name")
|
||||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
log.debug("onDetachedFromWindow")
|
println("onDetachedFromWindow")
|
||||||
super.onDetachedFromWindow()
|
super.onDetachedFromWindow()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
currentActivity.setPictureInPictureParams(
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAutoEnterEnabled(false)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentActivity.unregisterReceiver(actionReceiver)
|
|
||||||
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
|
|
||||||
VLCManager.listeners.clear()
|
|
||||||
|
|
||||||
mediaPlayer?.stop()
|
mediaPlayer?.stop()
|
||||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||||
|
|
||||||
@@ -370,7 +202,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvent(event: MediaPlayer.Event) {
|
override fun onEvent(event: MediaPlayer.Event) {
|
||||||
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
|
|
||||||
when (event.type) {
|
when (event.type) {
|
||||||
MediaPlayer.Event.Playing,
|
MediaPlayer.Event.Playing,
|
||||||
MediaPlayer.Event.Paused,
|
MediaPlayer.Event.Paused,
|
||||||
@@ -392,27 +223,35 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
"target" to "null", // Replace with actual target if needed
|
"target" to "null", // Replace with actual target if needed
|
||||||
"currentTime" to player.time.toInt(),
|
"currentTime" to player.time.toInt(),
|
||||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
"duration" to (player.media?.duration?.toInt() ?: 0),
|
||||||
"error" to false,
|
"error" to false
|
||||||
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
|
|
||||||
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Todo: make enum - string to prevent this when statement from becoming exhaustive
|
|
||||||
when (currentState) {
|
when (currentState) {
|
||||||
MediaPlayer.Event.Playing ->
|
MediaPlayer.Event.Playing -> {
|
||||||
|
stateInfo["isPlaying"] = true
|
||||||
|
stateInfo["isBuffering"] = false
|
||||||
stateInfo["state"] = "Playing"
|
stateInfo["state"] = "Playing"
|
||||||
MediaPlayer.Event.Paused ->
|
}
|
||||||
|
MediaPlayer.Event.Paused -> {
|
||||||
|
stateInfo["isPlaying"] = false
|
||||||
stateInfo["state"] = "Paused"
|
stateInfo["state"] = "Paused"
|
||||||
MediaPlayer.Event.Buffering ->
|
}
|
||||||
|
MediaPlayer.Event.Buffering -> {
|
||||||
|
stateInfo["isBuffering"] = true
|
||||||
stateInfo["state"] = "Buffering"
|
stateInfo["state"] = "Buffering"
|
||||||
|
}
|
||||||
MediaPlayer.Event.EncounteredError -> {
|
MediaPlayer.Event.EncounteredError -> {
|
||||||
|
Log.e("VlcPlayerView", "player.state ~ error")
|
||||||
stateInfo["state"] = "Error"
|
stateInfo["state"] = "Error"
|
||||||
onVideoLoadEnd(stateInfo);
|
onVideoLoadEnd(stateInfo);
|
||||||
}
|
}
|
||||||
MediaPlayer.Event.Opening ->
|
MediaPlayer.Event.Opening -> {
|
||||||
|
Log.d("VlcPlayerView", "player.state ~ opening")
|
||||||
stateInfo["state"] = "Opening"
|
stateInfo["state"] = "Opening"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
||||||
lastReportedState = currentState
|
lastReportedState = currentState
|
||||||
lastReportedIsPlaying = player.isPlaying
|
lastReportedIsPlaying = player.isPlaying
|
||||||
@@ -444,23 +283,4 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause(activity: Activity?) {
|
|
||||||
log.debug("Pausing activity...")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onResume(activity: Activity?) {
|
|
||||||
log.debug("Resuming activity...")
|
|
||||||
if (isPaused) play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"platforms": ["ios", "tvos", "android", "web"],
|
"platforms": ["ios", "tvos", "android", "web"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["VlcPlayerModule"],
|
"modules": ["VlcPlayerModule"]
|
||||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
NativeModulesProxy,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
EventSubscription,
|
Subscription,
|
||||||
} from "expo-modules-core";
|
} from "expo-modules-core";
|
||||||
|
|
||||||
import VlcPlayerModule from "./src/VlcPlayerModule";
|
import VlcPlayerModule from "./src/VlcPlayerModule";
|
||||||
@@ -18,11 +19,13 @@ import {
|
|||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "./src/VlcPlayer.types";
|
} from "./src/VlcPlayer.types";
|
||||||
|
|
||||||
const emitter = new EventEmitter(VlcPlayerModule);
|
const emitter = new EventEmitter(
|
||||||
|
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
|
||||||
|
);
|
||||||
|
|
||||||
export function addPlaybackStateListener(
|
export function addPlaybackStateListener(
|
||||||
listener: (event: PlaybackStatePayload) => void
|
listener: (event: PlaybackStatePayload) => void
|
||||||
): EventSubscription {
|
): Subscription {
|
||||||
return emitter.addListener<PlaybackStatePayload>(
|
return emitter.addListener<PlaybackStatePayload>(
|
||||||
"onPlaybackStateChanged",
|
"onPlaybackStateChanged",
|
||||||
listener
|
listener
|
||||||
@@ -31,7 +34,7 @@ export function addPlaybackStateListener(
|
|||||||
|
|
||||||
export function addVideoLoadStartListener(
|
export function addVideoLoadStartListener(
|
||||||
listener: (event: VideoLoadStartPayload) => void
|
listener: (event: VideoLoadStartPayload) => void
|
||||||
): EventSubscription {
|
): Subscription {
|
||||||
return emitter.addListener<VideoLoadStartPayload>(
|
return emitter.addListener<VideoLoadStartPayload>(
|
||||||
"onVideoLoadStart",
|
"onVideoLoadStart",
|
||||||
listener
|
listener
|
||||||
@@ -40,7 +43,7 @@ export function addVideoLoadStartListener(
|
|||||||
|
|
||||||
export function addVideoStateChangeListener(
|
export function addVideoStateChangeListener(
|
||||||
listener: (event: VideoStateChangePayload) => void
|
listener: (event: VideoStateChangePayload) => void
|
||||||
): EventSubscription {
|
): Subscription {
|
||||||
return emitter.addListener<VideoStateChangePayload>(
|
return emitter.addListener<VideoStateChangePayload>(
|
||||||
"onVideoStateChange",
|
"onVideoStateChange",
|
||||||
listener
|
listener
|
||||||
@@ -49,7 +52,7 @@ export function addVideoStateChangeListener(
|
|||||||
|
|
||||||
export function addVideoProgressListener(
|
export function addVideoProgressListener(
|
||||||
listener: (event: VideoProgressPayload) => void
|
listener: (event: VideoProgressPayload) => void
|
||||||
): EventSubscription {
|
): Subscription {
|
||||||
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
protocol SimpleAppLifecycleListener {
|
|
||||||
func applicationDidEnterBackground() -> Void
|
|
||||||
func applicationDidEnterForeground() -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
|
|
||||||
public func applicationDidBecomeActive(_ application: UIApplication) {
|
|
||||||
// The app has become active.
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillResignActive(_ application: UIApplication) {
|
|
||||||
// The app is about to become inactive.
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationDidEnterBackground(_ application: UIApplication) {
|
|
||||||
VLCManager.shared.listeners.forEach { listener in
|
|
||||||
listener.applicationDidEnterBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillEnterForeground(_ application: UIApplication) {
|
|
||||||
VLCManager.shared.listeners.forEach { listener in
|
|
||||||
listener.applicationDidEnterForeground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
// The app is about to terminate.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
class VLCManager {
|
|
||||||
static let shared = VLCManager()
|
|
||||||
var listeners: [SimpleAppLifecycleListener] = []
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'VlcPlayer'
|
s.name = 'VlcPlayer'
|
||||||
s.version = '4.0.0a10'
|
s.version = '1.0.0'
|
||||||
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,8 +10,8 @@ Pod::Spec.new do |s|
|
|||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
s.ios.dependency 'VLCKit', s.version
|
s.ios.dependency 'MobileVLCKit', '~> 3.6.1b1'
|
||||||
s.tvos.dependency 'VLCKit', s.version
|
s.tvos.dependency 'TVVLCKit', '~> 3.6.1b1'
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
# Swift/Objective-C compatibility
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
|
|||||||
@@ -16,20 +16,27 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -62,6 +69,14 @@ 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)
|
||||||
|
|||||||
@@ -1,176 +1,54 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
#if os(tvOS)
|
||||||
|
import TVVLCKit
|
||||||
|
#else
|
||||||
|
import MobileVLCKit
|
||||||
|
#endif
|
||||||
import UIKit
|
import UIKit
|
||||||
import VLCKit
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
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 {
|
||||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
|
private var mediaPlayer: VLCMediaPlayer?
|
||||||
|
private var videoView: UIView?
|
||||||
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
|
||||||
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 externalSubtitles: [[String: String]]?
|
private var lastProgressCall = Date().timeIntervalSince1970
|
||||||
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)
|
||||||
setupVLC()
|
setupView()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
VLCManager.shared.listeners.append(self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
private func setupVLC() {
|
|
||||||
vlc.setup(
|
private func setupView() {
|
||||||
parent: self,
|
DispatchQueue.main.async {
|
||||||
updatePlayerState: updatePlayerState,
|
self.backgroundColor = .black
|
||||||
updateVideoProgress: updateVideoProgress
|
self.videoView = UIView()
|
||||||
)
|
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() {
|
||||||
@@ -183,71 +61,57 @@ 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.vlc.player.play()
|
self.mediaPlayer?.play()
|
||||||
self.isPaused = false
|
self.isPaused = false
|
||||||
logger.debug("Play")
|
print("Play")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func pause() {
|
@objc func pause() {
|
||||||
self.vlc.player.pause()
|
self.mediaPlayer?.pause()
|
||||||
self.isPaused = true
|
self.isPaused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
@objc func seekTo(_ time: Int32) {
|
||||||
let wasPlaying = vlc.player.isPlaying
|
guard let player = self.mediaPlayer else { return }
|
||||||
|
|
||||||
|
let wasPlaying = player.isPlaying
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
self.pause()
|
self.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
if let duration = vlc.player.media?.length.intValue {
|
if let duration = player.media?.length.intValue {
|
||||||
logger.debug("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
|
||||||
vlc.player.time = VLCTime(int: seekTime)
|
player.time = VLCTime(int: seekTime)
|
||||||
self.updatePlayerState()
|
|
||||||
|
|
||||||
// Let mediaPlayerStateChanged handle play state change
|
if wasPlaying {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
self.play()
|
||||||
if wasPlaying {
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.updatePlayerState()
|
||||||
} else {
|
} else {
|
||||||
logger.error("Unable to retrieve video duration")
|
print("Error: Unable to retrieve video duration")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSource(_ source: [String: Any]) {
|
@objc func setSource(_ source: [String: Any]) {
|
||||||
logger.debug("Setting source...")
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if self.hasSource {
|
if self.hasSource {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||||
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
initOptions.append("--start-time=\(self.startPosition)")
|
||||||
|
|
||||||
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 {
|
||||||
logger.error("Invalid or empty URI")
|
print("Error: Invalid or empty URI")
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -256,13 +120,17 @@ 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 {
|
||||||
logger.debug("Loading network file: \(uri)")
|
print("Loading network file: \(uri)")
|
||||||
media = VLCMedia(url: URL(string: uri)!)
|
media = VLCMedia(url: URL(string: uri)!)
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Loading local file: \(uri)")
|
print("Loading local file: \(uri)")
|
||||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||||
media = VLCMedia(url: url)
|
media = VLCMedia(url: url)
|
||||||
} else {
|
} else {
|
||||||
@@ -270,84 +138,109 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Media options: \(mediaOptions)")
|
print("Debug: Media options: \(mediaOptions)")
|
||||||
media.addOptions(mediaOptions)
|
media.addOptions(mediaOptions)
|
||||||
|
|
||||||
self.vlc.player.media = media
|
self.mediaPlayer?.media = media
|
||||||
self.setInitialExternalSubtitles()
|
|
||||||
self.hasSource = true
|
self.hasSource = true
|
||||||
|
|
||||||
if autoplay {
|
if autoplay {
|
||||||
logger.info("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) {
|
||||||
print("Setting audio track: \(trackIndex)")
|
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
||||||
let track = self.vlc.player.audioTracks[trackIndex]
|
|
||||||
track.isSelectedExclusively = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||||
return vlc.player.audioTracks.enumerated().map {
|
guard let trackNames = mediaPlayer?.audioTrackNames,
|
||||||
return ["name": $1.trackName, "index": $0]
|
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
||||||
|
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) {
|
||||||
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
|
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||||
if trackIndex == -1 {
|
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
||||||
logger.debug("Disabling all subtitles")
|
print(
|
||||||
for track in self.vlc.player.textTracks {
|
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
||||||
track.isSelected = false
|
)
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let track = self.vlc.player.textTracks[trackIndex]
|
|
||||||
track.isSelectedExclusively = true;
|
|
||||||
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||||
guard let url = URL(string: subtitleURL) else {
|
guard let url = URL(string: subtitleURL) else {
|
||||||
logger.error("Invalid subtitle URL")
|
print("Error: Invalid subtitle URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
|
||||||
if result == 0 {
|
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||||
let internalName = "Track \(self.customSubtitles.count)"
|
if let result = result {
|
||||||
|
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))
|
||||||
logger.debug("Subtitle added with result: \(result) \(internalName)")
|
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Failed to add subtitle")
|
print("Failed to add subtitle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||||
if self.vlc.player.textTracks.count == 0 {
|
guard let mediaPlayer = self.mediaPlayer else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
let count = mediaPlayer.numberOfSubtitlesTracks
|
||||||
|
print("Debug: Number of subtitle tracks: \(count)")
|
||||||
|
|
||||||
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
guard count > 0 else {
|
||||||
if let customSubtitle = customSubtitles.first(where: {
|
return nil
|
||||||
$0.internalName == track.trackName
|
}
|
||||||
}) {
|
|
||||||
return ["name": customSubtitle.originalName, "index": index]
|
var tracks: [[String: Any]] = []
|
||||||
} else {
|
|
||||||
return ["name": track.trackName, "index": index]
|
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 {
|
||||||
|
tracks.append(["name": name, "index": index.intValue])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.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) {
|
||||||
logger.debug("Stopping media...")
|
|
||||||
guard !isStopping else {
|
guard !isStopping else {
|
||||||
completion?()
|
completion?()
|
||||||
return
|
return
|
||||||
@@ -374,98 +267,125 @@ 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
|
||||||
vlc.player.stop()
|
mediaPlayer?.stop()
|
||||||
|
|
||||||
// Remove observer
|
// Remove observer
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
|
||||||
// Clear the video view
|
// Clear the video view
|
||||||
vlc.getPlayerView().removeFromSuperview()
|
videoView?.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 media = self.vlc.player.media else { return }
|
guard let player = self.mediaPlayer else { return }
|
||||||
|
|
||||||
let currentTimeMs = self.vlc.player.time.intValue
|
let currentTimeMs = player.time.intValue
|
||||||
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
let durationMs = player.media?.length.intValue ?? 0
|
||||||
|
|
||||||
logger.debug("Current time: \(currentTimeMs)")
|
print("Debug: Current time: \(currentTimeMs)")
|
||||||
self.onVideoProgress?([
|
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||||
"currentTime": currentTimeMs,
|
if player.isPlaying && !self.isMediaReady {
|
||||||
"duration": durationMs,
|
self.isMediaReady = true
|
||||||
])
|
// Set external track subtitle when starting.
|
||||||
}
|
if let externalTrack = self.externalTrack {
|
||||||
|
if let name = externalTrack["name"], !name.isEmpty {
|
||||||
private func updatePlayerState() {
|
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
|
||||||
let player = self.vlc.player
|
self.setSubtitleURL(deliveryUrl, name: name)
|
||||||
self.onVideoStateChange?([
|
}
|
||||||
"target": self.reactTag ?? NSNull(),
|
}
|
||||||
"currentTime": player.time.intValue,
|
}
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
self.onVideoProgress?([
|
||||||
"error": false,
|
"currentTime": currentTimeMs,
|
||||||
"isPlaying": player.isPlaying,
|
"duration": durationMs,
|
||||||
"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
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
logger.debug("Deinitialization")
|
|
||||||
performStop()
|
performStop()
|
||||||
VLCManager.shared.listeners.removeAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SimpleAppLifecycleListener
|
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
||||||
extension VlcPlayerView: SimpleAppLifecycleListener {
|
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||||
func applicationDidEnterBackground() {
|
// self?.updateVideoProgress()
|
||||||
logger.debug("Entering background")
|
let timeNow = Date().timeIntervalSince1970
|
||||||
}
|
if timeNow - lastProgressCall >= 1 {
|
||||||
|
lastProgressCall = timeNow
|
||||||
func applicationDidEnterForeground() {
|
updateVideoProgress()
|
||||||
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
|
|
||||||
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
|
||||||
logger.debug("Player view is missing. Adding back as subview")
|
|
||||||
self.addSubview(self.vlc.getPlayerView())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current solution to fixing black screen when re-entering application
|
|
||||||
if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true }, !self.vlc.isMediaPlaying() {
|
|
||||||
videoTrack.isSelected = false
|
|
||||||
videoTrack.isSelectedExclusively = true
|
|
||||||
self.vlc.player.play()
|
|
||||||
self.vlc.player.pause()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -476,7 +396,9 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ 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;
|
||||||
@@ -39,7 +33,7 @@ export type VlcPlayerSource = {
|
|||||||
type?: string;
|
type?: string;
|
||||||
isNetwork?: boolean;
|
isNetwork?: boolean;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
externalTrack?: { name: string, DeliveryUrl: string };
|
||||||
initOptions?: any[];
|
initOptions?: any[];
|
||||||
mediaOptions?: { [key: string]: any };
|
mediaOptions?: { [key: string]: any };
|
||||||
startPosition?: number;
|
startPosition?: number;
|
||||||
@@ -70,11 +64,9 @@ 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>;
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ 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();
|
||||||
},
|
},
|
||||||
@@ -99,7 +96,6 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
onVideoProgress,
|
onVideoProgress,
|
||||||
onVideoLoadEnd,
|
onVideoLoadEnd,
|
||||||
onVideoError,
|
onVideoError,
|
||||||
onPipStarted,
|
|
||||||
...otherProps
|
...otherProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -126,7 +122,6 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
onVideoStateChange={onVideoStateChange}
|
onVideoStateChange={onVideoStateChange}
|
||||||
onVideoProgress={onVideoProgress}
|
onVideoProgress={onVideoProgress}
|
||||||
onVideoError={onVideoError}
|
onVideoError={onVideoError}
|
||||||
onPipStarted={onPipStarted}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
package.json
32
package.json
@@ -10,9 +10,9 @@
|
|||||||
"ios:tv": "EXPO_TV=1 expo run:ios",
|
"ios:tv": "EXPO_TV=1 expo run:ios",
|
||||||
"android": "EXPO_TV=0 expo run:android",
|
"android": "EXPO_TV=0 expo run:android",
|
||||||
"android:tv": "EXPO_TV=1 expo run:android",
|
"android:tv": "EXPO_TV=1 expo run:android",
|
||||||
"prebuild": "EXPO_TV=0 bun run clean",
|
"prebuild": "EXPO_TV=0 expo prebuild --clean",
|
||||||
"prebuild:tv": "EXPO_TV=1 bun run clean",
|
"prebuild:tv": "EXPO_TV=1 expo prebuild --clean",
|
||||||
"prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; bun run prebuild:tv",
|
"prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; EXPO_TV=1 expo prebuild --clean",
|
||||||
"test": "jest --watchAll",
|
"test": "jest --watchAll",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
@@ -27,13 +27,17 @@
|
|||||||
"@gorhom/bottom-sheet": "^5.1.0",
|
"@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-community/netinfo": "11.4.1",
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
"@react-native-menu/menu": "^1.2.2",
|
"@react-native-menu/menu": "^1.2.2",
|
||||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@shopify/flash-list": "1.7.3",
|
"@shopify/flash-list": "1.7.1",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
|
"@types/lodash": "^4.17.15",
|
||||||
|
"@types/react-native-vector-icons": "^6.4.18",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "^52.0.31",
|
"expo": "^52.0.31",
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
"expo-router": "~4.0.17",
|
"expo-router": "~4.0.17",
|
||||||
"expo-screen-orientation": "~8.0.4",
|
"expo-screen-orientation": "~8.0.4",
|
||||||
"expo-sensors": "~14.0.2",
|
"expo-sensors": "~14.0.2",
|
||||||
"expo-splash-screen": "~0.29.22",
|
"expo-splash-screen": "~0.29.21",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "~4.0.8",
|
"expo-system-ui": "~4.0.8",
|
||||||
"expo-task-manager": "~12.0.5",
|
"expo-task-manager": "~12.0.5",
|
||||||
@@ -66,6 +70,7 @@
|
|||||||
"expo-web-browser": "~14.0.2",
|
"expo-web-browser": "~14.0.2",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
|
"install": "^0.13.0",
|
||||||
"jotai": "^2.11.3",
|
"jotai": "^2.11.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
@@ -74,13 +79,13 @@
|
|||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "0.8.7",
|
"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.10.3",
|
"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.4",
|
"react-native-device-info": "^14.0.4",
|
||||||
"react-native-edge-to-edge": "^1.4.3",
|
"react-native-edge-to-edge": "^1.4.3",
|
||||||
"react-native-gesture-handler": "2.22.0",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"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",
|
||||||
@@ -91,9 +96,9 @@
|
|||||||
"react-native-progress": "^5.0.1",
|
"react-native-progress": "^5.0.1",
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "3.5.1",
|
"react-native-reanimated-carousel": "3.5.1",
|
||||||
"react-native-safe-area-context": "5.1.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.5.0",
|
"react-native-screens": "~4.4.0",
|
||||||
"react-native-svg": "15.11.1",
|
"react-native-svg": "15.8.0",
|
||||||
"react-native-tab-view": "^4.0.5",
|
"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",
|
||||||
@@ -102,7 +107,7 @@
|
|||||||
"react-native-video": "6.10.0",
|
"react-native-video": "6.10.0",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "13.13.2",
|
"react-native-webview": "13.12.5",
|
||||||
"sonner-native": "^0.17.0",
|
"sonner-native": "^0.17.0",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
@@ -120,10 +125,7 @@
|
|||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"react-test-renderer": "19.0.0",
|
"react-test-renderer": "19.0.0",
|
||||||
"typescript": "~5.7.3",
|
"typescript": "~5.7.3"
|
||||||
"@types/lodash": "^4.17.15",
|
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
|
||||||
"@types/uuid": "^10.0.0"
|
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"expo": {
|
"expo": {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
34
plugins/withGoogleCastActivity.js
Normal file
34
plugins/withGoogleCastActivity.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -18,13 +18,11 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import * as Application from "expo-application";
|
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 { FileInfo } from "expo-file-system";
|
||||||
import Notifications from "expo-notifications";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -38,6 +36,11 @@ 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";
|
||||||
|
const BackGroundDownloader = !Platform.isTV
|
||||||
|
? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader"))
|
||||||
|
: null;
|
||||||
|
// import * as Notifications from "expo-notifications";
|
||||||
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
@@ -55,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();
|
||||||
@@ -742,8 +747,5 @@ export function useDownload() {
|
|||||||
if (context === null) {
|
if (context === null) {
|
||||||
throw new Error("useDownload must be used within a DownloadProvider");
|
throw new Error("useDownload must be used within a DownloadProvider");
|
||||||
}
|
}
|
||||||
if (Platform.isTV) {
|
|
||||||
throw new Error("useDownload is not supported on TVOS");
|
|
||||||
}
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as Application from "expo-application";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo } from "react";
|
|
||||||
|
|
||||||
export type DownloadedItem = {
|
|
||||||
item: Partial<BaseItemDto>;
|
|
||||||
mediaSource: MediaSourceInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const processesAtom = atom<JobStatus[]>([]);
|
|
||||||
|
|
||||||
const DownloadContext = createContext<ReturnType<
|
|
||||||
typeof useDownloadProvider
|
|
||||||
> | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dummy download provider for tvOS
|
|
||||||
*/
|
|
||||||
function useDownloadProvider() {
|
|
||||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
|
||||||
|
|
||||||
const downloadedFiles: DownloadedItem[] = [];
|
|
||||||
|
|
||||||
const removeProcess = useCallback(async (id: string) => {}, []);
|
|
||||||
|
|
||||||
const startDownload = useCallback(async (process: JobStatus) => {
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startBackgroundDownload = useCallback(
|
|
||||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteAllFiles = async (): Promise<void> => {};
|
|
||||||
|
|
||||||
const deleteFile = async (id: string): Promise<void> => {};
|
|
||||||
|
|
||||||
const deleteItems = async (items: BaseItemDto[]) => {};
|
|
||||||
|
|
||||||
const cleanCacheDirectory = async () => {};
|
|
||||||
|
|
||||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
|
|
||||||
|
|
||||||
const appSizeUsage = useMemo(async () => {
|
|
||||||
return 0;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {}
|
|
||||||
|
|
||||||
function getDownloadedItemSize(itemId: string): number {
|
|
||||||
const size = storage.getString("downloadedItemSize-" + itemId);
|
|
||||||
return size ? parseInt(size) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
processes,
|
|
||||||
startBackgroundDownload,
|
|
||||||
downloadedFiles,
|
|
||||||
deleteAllFiles,
|
|
||||||
deleteFile,
|
|
||||||
deleteItems,
|
|
||||||
saveDownloadedItemInfo,
|
|
||||||
removeProcess,
|
|
||||||
setProcesses,
|
|
||||||
startDownload,
|
|
||||||
getDownloadedItem,
|
|
||||||
deleteFileByType,
|
|
||||||
appSizeUsage,
|
|
||||||
getDownloadedItemSize,
|
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
|
||||||
cleanCacheDirectory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const downloadProviderValue = useDownloadProvider();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DownloadContext.Provider value={downloadProviderValue}>
|
|
||||||
{children}
|
|
||||||
</DownloadContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDownload() {
|
|
||||||
const context = useContext(DownloadContext);
|
|
||||||
if (context === null) {
|
|
||||||
throw new Error("useDownload must be used within a DownloadProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -9,7 +7,6 @@ import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import axios, { AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
import { router, useSegments } from "expo-router";
|
import { router, useSegments } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -20,10 +17,16 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getDeviceName } from "react-native-device-info";
|
|
||||||
import uuid from "react-native-uuid";
|
import uuid from "react-native-uuid";
|
||||||
|
import { getDeviceName } from "react-native-device-info";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import {
|
||||||
|
useSplashScreenLoading,
|
||||||
|
useSplashScreenVisible,
|
||||||
|
} from "./SplashScreenProvider";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -61,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.26.1" },
|
clientInfo: { name: "Streamyfin", version: "0.25.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -85,12 +88,28 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
] = useSettings();
|
] = useSettings();
|
||||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||||
|
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["user", api],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getUserApi(api).getCurrentUser();
|
||||||
|
if (response.data) setUser(response.data);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
enabled: !!api,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchInterval: 1000 * 60,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
const headers = useMemo(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
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.26.1"`,
|
}, DeviceId="${deviceId}", Version="0.25.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -160,13 +179,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}, [api, secret, headers]);
|
}, [api, secret, headers]);
|
||||||
|
|
||||||
|
useInterval(pollQuickConnect, isPolling ? 1000 : null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useInterval(pollQuickConnect, isPolling ? 1000 : null);
|
|
||||||
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
|
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
|
||||||
|
|
||||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||||
@@ -283,7 +303,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
storage.delete("token");
|
storage.delete("token");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
|
||||||
setPluginSettings(undefined);
|
setPluginSettings(undefined);
|
||||||
await clearAllJellyseerData();
|
await clearAllJellyseerData();
|
||||||
},
|
},
|
||||||
@@ -292,44 +311,33 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
const { isLoading, isFetching } = useQuery({
|
||||||
const [initialLoaded, setInitialLoaded] = useState(false);
|
queryKey: [
|
||||||
|
"initializeJellyfin",
|
||||||
useEffect(() => {
|
user?.Id,
|
||||||
if (initialLoaded) {
|
api?.basePath,
|
||||||
setLoaded(true);
|
jellyfin?.clientInfo,
|
||||||
}
|
],
|
||||||
}, [initialLoaded]);
|
queryFn: async () => {
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeJellyfin = async () => {
|
|
||||||
if (!jellyfin) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const serverUrl = getServerUrlFromStorage();
|
const serverUrl = getServerUrlFromStorage();
|
||||||
const storedUser = getUserFromStorage();
|
const user = getUserFromStorage();
|
||||||
|
if (serverUrl && token && user?.Id && jellyfin) {
|
||||||
if (serverUrl && token) {
|
|
||||||
const apiInstance = jellyfin.createApi(serverUrl, token);
|
const apiInstance = jellyfin.createApi(serverUrl, token);
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
|
setUser(user);
|
||||||
if (storedUser?.Id) {
|
|
||||||
setUser(storedUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
|
||||||
setUser(response.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
return false;
|
||||||
setInitialLoaded(true);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
staleTime: 0,
|
||||||
initializeJellyfin();
|
enabled: !user?.Id || !api || !jellyfin,
|
||||||
}, [jellyfin]);
|
});
|
||||||
|
|
||||||
const contextValue: JellyfinContextValue = {
|
const contextValue: JellyfinContextValue = {
|
||||||
discoverServers,
|
discoverServers,
|
||||||
@@ -341,17 +349,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
let isLoadingOrFetching = isLoading || isFetching;
|
||||||
if (loaded) {
|
useProtectedRoute(user, isLoadingOrFetching);
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [loaded]);
|
|
||||||
|
|
||||||
useProtectedRoute(user, loaded);
|
// 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -363,24 +371,20 @@ export const useJellyfin = (): JellyfinContextValue => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
function useProtectedRoute(user: UserDto | null, loaded = false) {
|
function useProtectedRoute(user: UserDto | null, loading = false) {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded === false) return;
|
if (loading) return;
|
||||||
|
|
||||||
console.log("Loaded", user);
|
|
||||||
|
|
||||||
const inAuthGroup = segments[0] === "(auth)";
|
const inAuthGroup = segments[0] === "(auth)";
|
||||||
|
|
||||||
if (!user?.Id && inAuthGroup) {
|
if (!user?.Id && inAuthGroup) {
|
||||||
console.log("Redirected to login");
|
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
} else if (user?.Id && !inAuthGroup) {
|
} else if (user?.Id && !inAuthGroup) {
|
||||||
console.log("Redirected to home");
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
}
|
}
|
||||||
}, [user, segments, loaded]);
|
}, [user, segments, loading]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTokenFromStorage(): string | null {
|
export function getTokenFromStorage(): string | null {
|
||||||
|
|||||||
103
providers/SplashScreenProvider.tsx
Normal file
103
providers/SplashScreenProvider.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
|
||||||
|
type SplashScreenContextValue = {
|
||||||
|
registerLoadingComponent: () => () => void;
|
||||||
|
splashScreenVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SplashScreenContext = createContext<SplashScreenContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prevent splash screen from auto-hiding
|
||||||
|
void SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export const SplashScreenProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [splashScreenVisible, setSplashScreenVisible] = useState(true);
|
||||||
|
const loadingComponentsCount = useRef(0);
|
||||||
|
const isHidingRef = useRef(false);
|
||||||
|
|
||||||
|
const hideScreenIfNoLoadingComponents = async () => {
|
||||||
|
if (loadingComponentsCount.current === 0 && !isHidingRef.current) {
|
||||||
|
try {
|
||||||
|
isHidingRef.current = true;
|
||||||
|
await SplashScreen.hideAsync();
|
||||||
|
setSplashScreenVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to hide splash screen:", error);
|
||||||
|
} finally {
|
||||||
|
isHidingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerLoadingComponent = () => {
|
||||||
|
loadingComponentsCount.current += 1;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
loadingComponentsCount.current -= 1;
|
||||||
|
void hideScreenIfNoLoadingComponents();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextValue: SplashScreenContextValue = {
|
||||||
|
registerLoadingComponent,
|
||||||
|
splashScreenVisible,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplashScreenContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SplashScreenContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the Splash Screen until component is ready to be displayed.
|
||||||
|
*
|
||||||
|
* @param isLoading The loading state of the component
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
* ```
|
||||||
|
* const isLoading = loadSomething()
|
||||||
|
* useSplashScreenLoading(isLoading) // splash screen visible until isLoading is false
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSplashScreenLoading(isLoading: boolean) {
|
||||||
|
const context = useContext(SplashScreenContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useSplashScreenLoading must be used within a SplashScreenProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
return context.registerLoadingComponent();
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the visibility of the Splash Screen.
|
||||||
|
* @returns the visibility of the Splash Screen
|
||||||
|
*/
|
||||||
|
export function useSplashScreenVisible() {
|
||||||
|
const context = useContext(SplashScreenContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useSplashScreenVisible must be used within a SplashScreenProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context.splashScreenVisible;
|
||||||
|
}
|
||||||
@@ -132,8 +132,7 @@
|
|||||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||||
"hide_libraries": "Bibliotheken ausblenden",
|
"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.",
|
"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",
|
"disable_haptic_feedback": "Haptisches Feedback deaktivieren"
|
||||||
"default_quality": "Standardqualität"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
@@ -355,7 +354,7 @@
|
|||||||
"index": "Index:"
|
"index": "Index:"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Als Nächstes",
|
"next_up": "Als nächstes",
|
||||||
"no_items_to_display": "Keine Elemente zum Anzeigen",
|
"no_items_to_display": "Keine Elemente zum Anzeigen",
|
||||||
"cast_and_crew": "Besetzung und Crew",
|
"cast_and_crew": "Besetzung und Crew",
|
||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
|
|||||||
@@ -132,8 +132,7 @@
|
|||||||
"show_custom_menu_links": "Show Custom Menu Links",
|
"show_custom_menu_links": "Show Custom Menu Links",
|
||||||
"hide_libraries": "Hide Libraries",
|
"hide_libraries": "Hide Libraries",
|
||||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
"disable_haptic_feedback": "Disable Haptic Feedback"
|
||||||
"default_quality": "Default quality"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "Audio",
|
"audio_title": "Audio",
|
||||||
"set_audio_track": "Establecer pista del elemento anterior",
|
"set_audio_track": "Establecer pista de audio del elemento anterior",
|
||||||
"audio_language": "Idioma de audio",
|
"audio_language": "Idioma de audio",
|
||||||
"audio_hint": "Elige un idioma de audio por defecto.",
|
"audio_hint": "Elige un idioma de audio por defecto.",
|
||||||
"none": "Ninguno",
|
"none": "Ninguno",
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
"subtitle_title": "Subtítulos",
|
"subtitle_title": "Subtítulos",
|
||||||
"subtitle_language": "Idioma de subtítulos",
|
"subtitle_language": "Idioma de subtítulos",
|
||||||
"subtitle_mode": "Modo de subtítulos",
|
"subtitle_mode": "Modo de subtítulos",
|
||||||
"set_subtitle_track": "Establecer pista del elemento anterior",
|
"set_subtitle_track": "Establecer pista de subtítulos del elemento anterior",
|
||||||
"subtitle_size": "Tamaño de subtítulos",
|
"subtitle_size": "Tamaño de subtítulos",
|
||||||
"subtitle_hint": "Configurar preferencias de subtítulos.",
|
"subtitle_hint": "Configurar preferencias de subtítulos.",
|
||||||
"none": "Ninguno",
|
"none": "Ninguno",
|
||||||
@@ -132,8 +132,7 @@
|
|||||||
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
||||||
"hide_libraries": "Ocultar bibliotecas",
|
"hide_libraries": "Ocultar bibliotecas",
|
||||||
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
"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",
|
"disable_haptic_feedback": "Desactivar feedback háptico"
|
||||||
"default_quality": "Calidad por defecto"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Descargas",
|
"downloads_title": "Descargas",
|
||||||
|
|||||||
@@ -132,9 +132,7 @@
|
|||||||
"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 masquer dans l’onglet Bibliothèque et les sections de la page d’accueil.",
|
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l’onglet Bibliothèque et les sections de la page d’accueil.",
|
||||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
"disable_haptic_feedback": "Désactiver le retour haptique"
|
||||||
"default_quality": "Qualité par défaut"
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Téléchargements",
|
"downloads_title": "Téléchargements",
|
||||||
|
|||||||
@@ -1,458 +0,0 @@
|
|||||||
{
|
|
||||||
"login": {
|
|
||||||
"username_required": "Nome utente è obbligatorio",
|
|
||||||
"error_title": "Errore",
|
|
||||||
"login_title": "Accesso",
|
|
||||||
"login_to_title": "Accedi a",
|
|
||||||
"username_placeholder": "Nome utente",
|
|
||||||
"password_placeholder": "Password",
|
|
||||||
"login_button": "Accedi",
|
|
||||||
"quick_connect": "Connessione Rapida",
|
|
||||||
"enter_code_to_login": "Inserire {{code}} per accedere",
|
|
||||||
"failed_to_initiate_quick_connect": "Impossibile avviare la Connessione Rapida",
|
|
||||||
"got_it": "Capito",
|
|
||||||
"connection_failed": "Connessione fallita",
|
|
||||||
"could_not_connect_to_server": "Impossibile connettersi al server. Controllare l'URL e la connessione di rete.",
|
|
||||||
"an_unexpected_error_occured": "Si è verificato un errore inaspettato",
|
|
||||||
"change_server": "Cambiare il server",
|
|
||||||
"invalid_username_or_password": "Nome utente o password non validi",
|
|
||||||
"user_does_not_have_permission_to_log_in": "L'utente non ha il permesso di accedere",
|
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Il server sta impiegando troppo tempo per rispondere, riprovare più tardi",
|
|
||||||
"server_received_too_many_requests_try_again_later": "Il server ha ricevuto troppe richieste, riprovare più tardi.",
|
|
||||||
"there_is_a_server_error": "Si è verificato un errore del server",
|
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Si è verificato un errore imprevisto. L'URL del server è stato inserito correttamente?"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"enter_url_to_jellyfin_server": "Inserisci l'URL del tuo server Jellyfin",
|
|
||||||
"server_url_placeholder": "http(s)://tuo-server.com",
|
|
||||||
"connect_button": "Connetti",
|
|
||||||
"previous_servers": "server precedente",
|
|
||||||
"clear_button": "Cancella",
|
|
||||||
"search_for_local_servers": "Ricerca dei server locali",
|
|
||||||
"searching": "Cercando...",
|
|
||||||
"servers": "Servers"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"no_internet": "Nessun Internet",
|
|
||||||
"no_items": "Nessun oggetto",
|
|
||||||
"no_internet_message": "Non c'è da preoccuparsi, è ancora possibile guardare\n i contenuti scaricati.",
|
|
||||||
"go_to_downloads": "Vai agli elementi scaricati",
|
|
||||||
"oops": "Oops!",
|
|
||||||
"error_message": "Qualcosa è andato storto. \nEffetturare il logout e riaccedere.",
|
|
||||||
"continue_watching": "Continua a guardare",
|
|
||||||
"next_up": "Prossimo",
|
|
||||||
"recently_added_in": "Aggiunti di recente a {{libraryName}}",
|
|
||||||
"suggested_movies": "Film consigliati",
|
|
||||||
"suggested_episodes": "Episodi consigliati",
|
|
||||||
"intro": {
|
|
||||||
"welcome_to_streamyfin": "Benvenuto a Streamyfin",
|
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Un client gratuito e open-source per Jellyfin.",
|
|
||||||
"features_title": "Funzioni",
|
|
||||||
"features_description": "Streamyfin dispone di numerose funzioni e si integra con un'ampia gamma di software che si possono trovare nel menu delle impostazioni:",
|
|
||||||
"jellyseerr_feature_description": "Connettetevi alla vostra istanza Jellyseerr e richiedete i film direttamente nell'app.",
|
|
||||||
"downloads_feature_title": "Scaricamento",
|
|
||||||
"downloads_feature_description": "Scaricate film e serie tv da vedere offline. Utilizzate il metodo predefinito o installate il server di ottimizzazione per scaricare i file in background.",
|
|
||||||
"chromecast_feature_description": "Trasmettete film e serie tv ai vostri dispositivi Chromecast.",
|
|
||||||
"centralised_settings_plugin_title": "Impostazioni dei Plugin Centralizzate",
|
|
||||||
"centralised_settings_plugin_description": "Configura le impostazioni da una posizione centralizzata sul server Jellyfin. Tutte le impostazioni del client per tutti gli utenti saranno sincronizzate automaticamente.",
|
|
||||||
"done_button": "Fatto",
|
|
||||||
"go_to_settings_button": "Vai alle impostazioni",
|
|
||||||
"read_more": "Leggi di più"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"settings_title": "Impostazioni",
|
|
||||||
"log_out_button": "Esci",
|
|
||||||
"user_info": {
|
|
||||||
"user_info_title": "Info utente",
|
|
||||||
"user": "Utente",
|
|
||||||
"server": "Server",
|
|
||||||
"token": "Token",
|
|
||||||
"app_version": "Versione dell'App"
|
|
||||||
},
|
|
||||||
"quick_connect": {
|
|
||||||
"quick_connect_title": "Connessione Rapida",
|
|
||||||
"authorize_button": "Autorizza Connessione Rapida",
|
|
||||||
"enter_the_quick_connect_code": "Inserisci il codice per la Connessione Rapida...",
|
|
||||||
"success": "Successo",
|
|
||||||
"quick_connect_autorized": "Connessione Rapida autorizzata",
|
|
||||||
"error": "Errore",
|
|
||||||
"invalid_code": "Codice invalido",
|
|
||||||
"authorize": "Autorizza"
|
|
||||||
},
|
|
||||||
"media_controls": {
|
|
||||||
"media_controls_title": "Controlli multimediali",
|
|
||||||
"forward_skip_length": "Lunghezza del salto in avanti",
|
|
||||||
"rewind_length": "Lunghezza del riavvolgimento",
|
|
||||||
"seconds_unit": "s"
|
|
||||||
},
|
|
||||||
"audio": {
|
|
||||||
"audio_title": "Audio",
|
|
||||||
"set_audio_track": "Imposta la traccia audio dall'elemento precedente",
|
|
||||||
"audio_language": "Lingua Audio",
|
|
||||||
"audio_hint": "Scegli la lingua audio predefinita.",
|
|
||||||
"none": "Nessuno",
|
|
||||||
"language": "Lingua"
|
|
||||||
},
|
|
||||||
"subtitles": {
|
|
||||||
"subtitle_title": "Sottotitoli",
|
|
||||||
"subtitle_language": "Lingua dei sottotitoli",
|
|
||||||
"subtitle_mode": "Modalità dei sottotitoli",
|
|
||||||
"set_subtitle_track": "Imposta la traccia dei sottotitoli dall'elemento precedente",
|
|
||||||
"subtitle_size": "Dimensione dei sottotitoli",
|
|
||||||
"subtitle_hint": "Configura la preferenza dei sottotitoli.",
|
|
||||||
"none": "Nessuno",
|
|
||||||
"language": "Lingua",
|
|
||||||
"loading": "Caricamento",
|
|
||||||
"modes": {
|
|
||||||
"Default": "Predefinito",
|
|
||||||
"Smart": "Intelligente",
|
|
||||||
"Always": "Sempre",
|
|
||||||
"None": "Nessuno",
|
|
||||||
"OnlyForced": "Solo forzati"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"other_title": "Altro",
|
|
||||||
"auto_rotate": "Rotazione automatica",
|
|
||||||
"video_orientation": "Orientamento del video",
|
|
||||||
"orientation": "Orientamento",
|
|
||||||
"orientations": {
|
|
||||||
"DEFAULT": "Predefinito",
|
|
||||||
"ALL": "Tutto",
|
|
||||||
"PORTRAIT": "Verticale",
|
|
||||||
"PORTRAIT_UP": "Verticale sopra",
|
|
||||||
"PORTRAIT_DOWN": "Verticale sotto",
|
|
||||||
"LANDSCAPE": "Orizzontale",
|
|
||||||
"LANDSCAPE_LEFT": "Orizzontale sinitra",
|
|
||||||
"LANDSCAPE_RIGHT": "Orizzontale destra",
|
|
||||||
"OTHER": "Altro",
|
|
||||||
"UNKNOWN": "Sconosciuto"
|
|
||||||
},
|
|
||||||
"safe_area_in_controls": "Area sicura per i controlli",
|
|
||||||
"show_custom_menu_links": "Mostra i link del menu personalizzato",
|
|
||||||
"hide_libraries": "Nascondi Librerie",
|
|
||||||
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
|
|
||||||
"disable_haptic_feedback": "Disabilita il feedback aptico",
|
|
||||||
"default_quality": "Qualità predefinita"
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Scaricamento",
|
|
||||||
"download_method": "Metodo per lo scaricamento",
|
|
||||||
"remux_max_download": "Numero di Remux da scaricare al massimo",
|
|
||||||
"auto_download": "Scaricamento automatico",
|
|
||||||
"optimized_versions_server": "Versioni del server di ottimizzazione",
|
|
||||||
"save_button": "Salva",
|
|
||||||
"optimized_server": "Server di ottimizzazione",
|
|
||||||
"optimized": "Ottimizzato",
|
|
||||||
"default": "Predefinito",
|
|
||||||
"optimized_version_hint": "Inserire l'URL del server di ottimizzazione. L'URL deve includere http o https e, facoltativamente, la porta.",
|
|
||||||
"read_more_about_optimized_server": "Per saperne di più sul server di ottimizzazione.",
|
|
||||||
"url":"URL",
|
|
||||||
"server_url_placeholder": "http(s)://dominio.org:porta"
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"plugins_title": "Plugin",
|
|
||||||
"jellyseerr": {
|
|
||||||
"jellyseerr_warning": "Questa integrazione è in fase iniziale. Aspettarsi cambiamenti.",
|
|
||||||
"server_url": "URL del Server",
|
|
||||||
"server_url_hint": "Esempio: http(s)://tuo-host.url\n(aggiungere la porta se richiesto)",
|
|
||||||
"server_url_placeholder": "URL di Jellyseerr...",
|
|
||||||
"password": "Password",
|
|
||||||
"password_placeholder": "Inserire la password per l'utente {{username}} di Jellyfin",
|
|
||||||
"save_button": "Salva",
|
|
||||||
"clear_button": "Cancella",
|
|
||||||
"login_button": "Accedi",
|
|
||||||
"total_media_requests": "Totale di richieste di media",
|
|
||||||
"movie_quota_limit": "Limite di quota per i film",
|
|
||||||
"movie_quota_days": "Giorni di quota per i film",
|
|
||||||
"tv_quota_limit": "Limite di quota per le serie TV",
|
|
||||||
"tv_quota_days": "Giorni di quota per le serie TV",
|
|
||||||
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
|
|
||||||
"unlimited": "Illimitato"
|
|
||||||
},
|
|
||||||
"marlin_search": {
|
|
||||||
"enable_marlin_search": "Abilita la ricerca Marlin ",
|
|
||||||
"url": "URL",
|
|
||||||
"server_url_placeholder": "http(s)://dominio.org:porta",
|
|
||||||
"marlin_search_hint": "Inserire l'URL del server Marlin. L'URL deve includere http o https e, facoltativamente, la porta.",
|
|
||||||
"read_more_about_marlin": "Leggi di più su Marlin.",
|
|
||||||
"save_button": "Salva",
|
|
||||||
"toasts": {
|
|
||||||
"saved": "Salvato"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"storage_title": "Spazio",
|
|
||||||
"app_usage": "App {{usedSpace}}%",
|
|
||||||
"device_usage": "Dispositivo {{availableSpace}}%",
|
|
||||||
"size_used": "{{used}} di {{total}} usato",
|
|
||||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati"
|
|
||||||
},
|
|
||||||
"intro": {
|
|
||||||
"show_intro": "Mostra intro",
|
|
||||||
"reset_intro": "Ripristina intro"
|
|
||||||
},
|
|
||||||
"logs": {
|
|
||||||
"logs_title": "Log",
|
|
||||||
"no_logs_available": "Nessun log disponibile",
|
|
||||||
"delete_all_logs": "Cancella tutti i log"
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"title": "Lingue",
|
|
||||||
"app_language": "Lingua dell'App",
|
|
||||||
"app_language_description": "Selezione la lingua dell'app.",
|
|
||||||
"system": "Sistema"
|
|
||||||
},
|
|
||||||
"toasts":{
|
|
||||||
"error_deleting_files": "Errore nella cancellazione dei file",
|
|
||||||
"background_downloads_enabled": "Scaricamento in background abilitato",
|
|
||||||
"background_downloads_disabled": "Scaricamento in background disabilitato",
|
|
||||||
"connected": "Connesso",
|
|
||||||
"could_not_connect": "Non è stato possibile connettersi",
|
|
||||||
"invalid_url": "URL invalido"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Scaricati",
|
|
||||||
"tvseries": "Serie TV",
|
|
||||||
"movies": "Film",
|
|
||||||
"queue": "Coda",
|
|
||||||
"queue_hint": "La coda e gli elementi scaricati saranno persi con il riavvio dell'app",
|
|
||||||
"no_items_in_queue": "Nessun elemento in coda",
|
|
||||||
"no_downloaded_items": "Nessun elemento scaricato",
|
|
||||||
"delete_all_movies_button": "Cancella tutti i film",
|
|
||||||
"delete_all_tvseries_button": "Cancella tutte le serie TV",
|
|
||||||
"delete_all_button": "Cancella tutti",
|
|
||||||
"active_download": "Scaricamento in corso",
|
|
||||||
"no_active_downloads": "Nessun scaricamento in corso",
|
|
||||||
"active_downloads": "Scaricamenti in corso",
|
|
||||||
"new_app_version_requires_re_download": "La nuova verione dell'app richiede di scaricare nuovamente i contenuti",
|
|
||||||
"new_app_version_requires_re_download_description": "Il nuovo aggiornamento richiede di scaricare nuovamente i contenuti. Rimuovere tutti i contenuti scaricati e riprovare.",
|
|
||||||
"back": "Indietro",
|
|
||||||
"delete": "Cancella",
|
|
||||||
"something_went_wrong": "Qualcosa è andato storto",
|
|
||||||
"could_not_get_stream_url_from_jellyfin": "Impossibile ottenere l'URL del flusso da Jellyfin",
|
|
||||||
"eta": "ETA {{eta}}",
|
|
||||||
"methods": "Metodi",
|
|
||||||
"toasts": {
|
|
||||||
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
|
|
||||||
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
|
|
||||||
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
|
|
||||||
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
|
|
||||||
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
|
|
||||||
"download_cancelled": "Scaricamento annullato",
|
|
||||||
"could_not_cancel_download": "Impossibile annullare lo scaricamento",
|
|
||||||
"download_completed": "Scaricamento completato",
|
|
||||||
"download_started_for": "Scaricamento iniziato per {{item}}",
|
|
||||||
"item_is_ready_to_be_downloaded": "{{item}} è pronto per essere scaricato",
|
|
||||||
"download_stated_for_item": "Scaricamento iniziato per {{item}}",
|
|
||||||
"download_failed_for_item": "Scaricamento fallito per {{item}} - {{error}}",
|
|
||||||
"download_completed_for_item": "Scaricamento completato per {{item}}",
|
|
||||||
"queued_item_for_optimization": "Messo in coda {{item}} per l'ottimizzazione",
|
|
||||||
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
|
|
||||||
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
|
|
||||||
"no_response_received_from_server": "No response received from the server",
|
|
||||||
"error_setting_up_the_request": "Error setting up the request",
|
|
||||||
"failed_to_start_download_for_item_unexpected_error": "Impossibile avviare il download per {{item}}: Errore imprevisto",
|
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Tutti i file, le cartelle e i processi sono stati eliminati con successo.",
|
|
||||||
"an_error_occured_while_deleting_files_and_jobs": "Si è verificato un errore durante l'eliminazione di file e processi",
|
|
||||||
"go_to_downloads": "Vai agli elementi scaricati"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"search": {
|
|
||||||
"search_here": "Cerca qui...",
|
|
||||||
"search": "Cerca...",
|
|
||||||
"x_items": "{{count}} elementi",
|
|
||||||
"library": "Libreria",
|
|
||||||
"discover": "Scopri",
|
|
||||||
"no_results": "Nessun risultato",
|
|
||||||
"no_results_found_for": "Nessun risultato trovato per",
|
|
||||||
"movies": "Film",
|
|
||||||
"series": "Serie",
|
|
||||||
"episodes": "Episodi",
|
|
||||||
"collections": "Collezioni",
|
|
||||||
"actors": "Attori",
|
|
||||||
"request_movies": "Film Richiesti",
|
|
||||||
"request_series": "Serie Richieste",
|
|
||||||
"recently_added": "Aggiunti di Recente",
|
|
||||||
"recent_requests": "Richiesti di Recente",
|
|
||||||
"plex_watchlist": "Plex Watchlist",
|
|
||||||
"trending": "In tendenza",
|
|
||||||
"popular_movies": "Film Popolari",
|
|
||||||
"movie_genres": "Generi Film",
|
|
||||||
"upcoming_movies": "Film in arrivo",
|
|
||||||
"studios": "Studio",
|
|
||||||
"popular_tv": "Serie Popolari",
|
|
||||||
"tv_genres": "Generi Televisivi",
|
|
||||||
"upcoming_tv": "Serie in Arrivo",
|
|
||||||
"networks": "Network",
|
|
||||||
"tmdb_movie_keyword": "TMDB Parola chiave del film",
|
|
||||||
"tmdb_movie_genre": "TMDB Genere Film",
|
|
||||||
"tmdb_tv_keyword": "TMDB Parola chiave della serie",
|
|
||||||
"tmdb_tv_genre": "TMDB Genere Televisivo",
|
|
||||||
"tmdb_search": "TMDB Cerca",
|
|
||||||
"tmdb_studio": "TMDB Studio",
|
|
||||||
"tmdb_network": "TMDB Network",
|
|
||||||
"tmdb_movie_streaming_services": "TMDB Servizi di Streaming di Film",
|
|
||||||
"tmdb_tv_streaming_services": "TMDB Servizi di Streaming di Serie"
|
|
||||||
},
|
|
||||||
"library": {
|
|
||||||
"no_items_found": "Nessun elemento trovato",
|
|
||||||
"no_results": "Nessun risultato",
|
|
||||||
"no_libraries_found": "Nessuna libreria trovata",
|
|
||||||
"item_types": {
|
|
||||||
"movies": "film",
|
|
||||||
"series": "serie TV",
|
|
||||||
"boxsets": "cofanetti",
|
|
||||||
"items": "elementi"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"display": "Display",
|
|
||||||
"row": "Fila",
|
|
||||||
"list": "Lista",
|
|
||||||
"image_style": "Stile dell'immagine",
|
|
||||||
"poster": "Poster",
|
|
||||||
"cover": "Cover",
|
|
||||||
"show_titles": "Mostra titoli",
|
|
||||||
"show_stats": "Mostra statistiche"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"genres": "Generi",
|
|
||||||
"years": "Anni",
|
|
||||||
"sort_by": "Ordina per",
|
|
||||||
"sort_order": "Criterio di ordinamento",
|
|
||||||
"tags": "Tag"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"favorites": {
|
|
||||||
"series": "Serie TV",
|
|
||||||
"movies": "Film",
|
|
||||||
"episodes": "Episodi",
|
|
||||||
"videos": "Video",
|
|
||||||
"boxsets": "Boxset",
|
|
||||||
"playlists": "Playlist"
|
|
||||||
},
|
|
||||||
"custom_links": {
|
|
||||||
"no_links": "Nessun link"
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"error": "Errore",
|
|
||||||
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
|
||||||
"an_error_occured_while_playing_the_video": "Si è verificato un errore durante la riproduzione del video. Controllare i log nelle impostazioni.",
|
|
||||||
"client_error": "Errore del client",
|
|
||||||
"could_not_create_stream_for_chromecast": "Impossibile creare uno stream per Chromecast",
|
|
||||||
"message_from_server": "Messaggio dal server: {{messagge}}",
|
|
||||||
"video_has_finished_playing": "La riproduzione del video è terminata!",
|
|
||||||
"no_video_source": "Nessuna sorgente video...",
|
|
||||||
"next_episode": "Prossimo Episodio",
|
|
||||||
"refresh_tracks": "Aggiorna tracce",
|
|
||||||
"subtitle_tracks": "Tracce di sottotitoli:",
|
|
||||||
"audio_tracks": "Tracce audio:",
|
|
||||||
"playback_state": "Stato della riproduzione:",
|
|
||||||
"no_data_available": "Nessun dato disponibile",
|
|
||||||
"index": "Indice:"
|
|
||||||
},
|
|
||||||
"item_card": {
|
|
||||||
"next_up": "Il prossimo",
|
|
||||||
"no_items_to_display": "Nessun elemento da visualizzare",
|
|
||||||
"cast_and_crew": "Cast e Equipaggio",
|
|
||||||
"series": "Serie",
|
|
||||||
"seasons": "Stagioni",
|
|
||||||
"season": "Stagione",
|
|
||||||
"no_episodes_for_this_season": "Nessun episodio per questa stagione",
|
|
||||||
"overview": "Panoramica",
|
|
||||||
"more_with": "Altri con {{name}}",
|
|
||||||
"similar_items": "Elementi simili",
|
|
||||||
"no_similar_items_found": "Non sono stati trovati elementi simili",
|
|
||||||
"video": "Video",
|
|
||||||
"more_details": "Più dettagli",
|
|
||||||
"quality": "Qualità",
|
|
||||||
"audio": "Audio",
|
|
||||||
"subtitles": "Sottotitoli",
|
|
||||||
"show_more": "Mostra di più",
|
|
||||||
"show_less": "Mostra di meno",
|
|
||||||
"appeared_in": "Apparso in",
|
|
||||||
"could_not_load_item": "Impossibile caricare l'elemento",
|
|
||||||
"none": "Nessuno",
|
|
||||||
"download": {
|
|
||||||
"download_season": "Scarica Stagione",
|
|
||||||
"download_series": "Scarica Serie",
|
|
||||||
"download_episode": "Scarica Episodio",
|
|
||||||
"download_movie": "Scarica Film",
|
|
||||||
"download_x_item": "Scarica {{item_count}} elementi",
|
|
||||||
"download_button": "Scarica",
|
|
||||||
"using_optimized_server": "Utilizzando il server di ottimizzazione",
|
|
||||||
"using_default_method": "Utilizzando il metodo predefinito"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"live_tv": {
|
|
||||||
"next": "Prossimo",
|
|
||||||
"previous": "Precedente",
|
|
||||||
"live_tv": "TV in diretta",
|
|
||||||
"coming_soon": "Prossimamente",
|
|
||||||
"on_now": "In onda ora",
|
|
||||||
"shows": "Programmi",
|
|
||||||
"movies": "Film",
|
|
||||||
"sports": "Sport",
|
|
||||||
"for_kids": "Per Bambini",
|
|
||||||
"news": "Notiziari"
|
|
||||||
},
|
|
||||||
"jellyseerr":{
|
|
||||||
"confirm": "Conferma",
|
|
||||||
"cancel": "Cancella",
|
|
||||||
"yes": "Si",
|
|
||||||
"whats_wrong": "Cosa c'è che non va?",
|
|
||||||
"issue_type": "Tipo di problema",
|
|
||||||
"select_an_issue": "Seleziona un problema",
|
|
||||||
"types": "Tipi",
|
|
||||||
"describe_the_issue": "(facoltativo) Descrivere il problema...",
|
|
||||||
"submit_button": "Invia",
|
|
||||||
"report_issue_button": "Segnalare il problema",
|
|
||||||
"request_button": "Richiedi",
|
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Sei sicuro di voler richiedere tutte le stagioni?",
|
|
||||||
"failed_to_login": "Accesso non riuscito",
|
|
||||||
"cast": "Cast",
|
|
||||||
"details": "Dettagli",
|
|
||||||
"status": "Stato",
|
|
||||||
"original_title": "Titolo originale",
|
|
||||||
"series_type": "Tipo di Serie",
|
|
||||||
"release_dates": "Date di Uscita",
|
|
||||||
"first_air_date": "Prima Data di Messa in Onda",
|
|
||||||
"next_air_date": "Prossima Data di Messa in Onda",
|
|
||||||
"revenue": "Ricavi",
|
|
||||||
"budget": "Budget",
|
|
||||||
"original_language": "Lingua Originale",
|
|
||||||
"production_country": "Paese di Produzione",
|
|
||||||
"studios": "Studio",
|
|
||||||
"network": "Network",
|
|
||||||
"currently_streaming_on": "Attualmente in streaming su",
|
|
||||||
"advanced": "Avanzate",
|
|
||||||
"request_as": "Richiedi Come",
|
|
||||||
"tags": "Tag",
|
|
||||||
"quality_profile": "Profilo qualità",
|
|
||||||
"root_folder": "Cartella radice",
|
|
||||||
"season_x": "Stagione {{seasons}}",
|
|
||||||
"season_number": "Stagione {{season_number}}",
|
|
||||||
"number_episodes": "{{episode_number}} Episodio",
|
|
||||||
"born": "Nato",
|
|
||||||
"appearances": "Aspetto",
|
|
||||||
"toasts": {
|
|
||||||
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
|
|
||||||
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
|
|
||||||
"failed_to_test_jellyseerr_server_url": "Fallito il test dell'url del server jellyseerr",
|
|
||||||
"issue_submitted": "Problema inviato!",
|
|
||||||
"requested_item": "Richiesto {{item}}!",
|
|
||||||
"you_dont_have_permission_to_request": "Non hai il permesso di richiedere!",
|
|
||||||
"something_went_wrong_requesting_media": "Qualcosa è andato storto nella richiesta dei media!"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"home": "Home",
|
|
||||||
"search": "Cerca",
|
|
||||||
"library": "Libreria",
|
|
||||||
"custom_links": "Collegamenti personalizzati",
|
|
||||||
"favorites": "Preferiti"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,458 +0,0 @@
|
|||||||
{
|
|
||||||
"login": {
|
|
||||||
"username_required": "Gebruikersnaam is verplicht",
|
|
||||||
"error_title": "Fout",
|
|
||||||
"login_title": "Aanmelden",
|
|
||||||
"login_to_title": "Aanmelden bij",
|
|
||||||
"username_placeholder": "Gebruikersnaam",
|
|
||||||
"password_placeholder": "Wachtwoord",
|
|
||||||
"login_button": "Aanmelden",
|
|
||||||
"quick_connect": "Snel Verbinden",
|
|
||||||
"enter_code_to_login": "Vul code {{code}} in om aan te melden",
|
|
||||||
"failed_to_initiate_quick_connect": "Gefaald om Snel Verbinden op te starten",
|
|
||||||
"got_it": "Begrepen",
|
|
||||||
"connection_failed": "Verbinding gefaald",
|
|
||||||
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
|
|
||||||
"an_unexpected_error_occured": "Er is een onverwachte fout opgetreden",
|
|
||||||
"change_server": "Verander server",
|
|
||||||
"invalid_username_or_password": "Ongeldige gebruikersnaam of wachtwoord",
|
|
||||||
"user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden",
|
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "De server doet er te lang over om te antwoorden, probeer later opnieuw",
|
|
||||||
"server_received_too_many_requests_try_again_later": "De server heeft te veel aanvragen ontvangen, probeer later opnieuw",
|
|
||||||
"there_is_a_server_error": "Er is een serverfout",
|
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"enter_url_to_jellyfin_server": "Geef de URL van je Jellyfin server in",
|
|
||||||
"server_url_placeholder": "http(s)://je-server.com",
|
|
||||||
"connect_button": "Verbinden",
|
|
||||||
"previous_servers": "vorige servers",
|
|
||||||
"clear_button": "Wissen",
|
|
||||||
"search_for_local_servers": "Zoek naar lokale servers",
|
|
||||||
"searching": "Zoeken...",
|
|
||||||
"servers": "Servers"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"no_internet": "Geen Internet",
|
|
||||||
"no_items": "Geen items",
|
|
||||||
"no_internet_message": "Geen zorgen, je kan nog steeds\ngedownloade content bekijken",
|
|
||||||
"go_to_downloads": "Ga naar downloads",
|
|
||||||
"oops": "Oeps!",
|
|
||||||
"error_message": "Er ging iets fout\nGelieve af en aan te melden.",
|
|
||||||
"continue_watching": "Verder Kijken",
|
|
||||||
"next_up": "Volgende",
|
|
||||||
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
|
|
||||||
"suggested_movies": "Voorgestelde Films",
|
|
||||||
"suggested_episodes": "Voorgestelde Afleveringen",
|
|
||||||
"intro": {
|
|
||||||
"welcome_to_streamyfin": "Welkom bij Streamyfin",
|
|
||||||
"a_free_and_open_source_client_for_jellyfin": "Een gratis en open-source client voor Jellyfin.",
|
|
||||||
"features_title": "Functies",
|
|
||||||
"features_description": "Streamyfin heeft een heleboel functies en integreert met een breed scala aan software die je kunt vinden in het instellingenmenu, onder andere:",
|
|
||||||
"jellyseerr_feature_description": "Verbind met je Jellyseerr instantie en vraag films direct in de app aan.",
|
|
||||||
"downloads_feature_title": "Downloads",
|
|
||||||
"downloads_feature_description": "Download films en series om offline te kijken. Gebruik de standaardmethode of installeer de optimalisatieserver om bestanden op de achtergrond te downloaden.",
|
|
||||||
"chromecast_feature_description": "Cast films en series naar je Chromecast toestellen.",
|
|
||||||
"centralised_settings_plugin_title": "Plugin voor gecentraliseerde instellingen",
|
|
||||||
"centralised_settings_plugin_description": "Configureer instellingen vanaf een centrale locatie op je Jellyfin server. Alle clientinstellingen voor alle gebruikers worden automatisch gesynchroniseerd.",
|
|
||||||
"done_button": "Gedaan",
|
|
||||||
"go_to_settings_button": "Go naar instellingen",
|
|
||||||
"read_more": "Lees meer"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"settings_title": "Instellingen",
|
|
||||||
"log_out_button": "Afmelden",
|
|
||||||
"user_info": {
|
|
||||||
"user_info_title": "Gebruiker Info",
|
|
||||||
"user": "Gebruiker",
|
|
||||||
"server": "Server",
|
|
||||||
"token": "Token",
|
|
||||||
"app_version": "App Versie"
|
|
||||||
},
|
|
||||||
"quick_connect": {
|
|
||||||
"quick_connect_title": "Snel Verbinden",
|
|
||||||
"authorize_button": "Snel Verbinden toestaan",
|
|
||||||
"enter_the_quick_connect_code": "Vul de Snel Verbinden code in...",
|
|
||||||
"success": "Succes",
|
|
||||||
"quick_connect_autorized": "Snel Verbinden toegestaan",
|
|
||||||
"error": "Fout",
|
|
||||||
"invalid_code": "Ongeldige code",
|
|
||||||
"authorize": "Toestaan"
|
|
||||||
},
|
|
||||||
"media_controls": {
|
|
||||||
"media_controls_title": "Media Bedieningen",
|
|
||||||
"forward_skip_length": "Duur voorwaarts overslaan",
|
|
||||||
"rewind_length": "Duur terugspeolen",
|
|
||||||
"seconds_unit": "s"
|
|
||||||
},
|
|
||||||
"audio": {
|
|
||||||
"audio_title": "Audio",
|
|
||||||
"set_audio_track": "Gebruik Audio Track Van Vorig Item",
|
|
||||||
"audio_language": "Audio taal",
|
|
||||||
"audio_hint": "Kies een standaard audio taal.",
|
|
||||||
"none": "Geen",
|
|
||||||
"language": "Taal"
|
|
||||||
},
|
|
||||||
"subtitles": {
|
|
||||||
"subtitle_title": "Ondertitels",
|
|
||||||
"subtitle_language": "Ondertitel taal",
|
|
||||||
"subtitle_mode": "Ondertitle Modus",
|
|
||||||
"set_subtitle_track": "Gebruik Ondertitel Track Van Vorig Item",
|
|
||||||
"subtitle_size": "Ondertitel Grootte",
|
|
||||||
"subtitle_hint": "Stel ondertitel voorkeuren in.",
|
|
||||||
"none": "Geen",
|
|
||||||
"language": "Taal",
|
|
||||||
"loading": "Laden",
|
|
||||||
"modes": {
|
|
||||||
"Default": "Standaard",
|
|
||||||
"Smart": "Slim",
|
|
||||||
"Always": "Altijd",
|
|
||||||
"None": "Geen",
|
|
||||||
"OnlyForced": "Alleen Geforceeerd"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"other_title": "Andere",
|
|
||||||
"auto_rotate": "Automatisch draaien",
|
|
||||||
"video_orientation": "Video oriëntatie",
|
|
||||||
"orientation": "Oriëntatie",
|
|
||||||
"orientations": {
|
|
||||||
"DEFAULT": "Standaard",
|
|
||||||
"ALL": "Alle",
|
|
||||||
"PORTRAIT": "Portret",
|
|
||||||
"PORTRAIT_UP": "Portret Omhoog",
|
|
||||||
"PORTRAIT_DOWN": "Portret Omlaag",
|
|
||||||
"LANDSCAPE": "Landschap",
|
|
||||||
"LANDSCAPE_LEFT": "Landschap Links",
|
|
||||||
"LANDSCAPE_RIGHT": "Landschap Rechts",
|
|
||||||
"OTHER": "Andere",
|
|
||||||
"UNKNOWN": "Onbekend"
|
|
||||||
},
|
|
||||||
"safe_area_in_controls": "Veilig gebied in bedieningen",
|
|
||||||
"show_custom_menu_links": "Aangepaste menulinks tonen",
|
|
||||||
"hide_libraries": "Verberg Bibliotheken",
|
|
||||||
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheek tab en hoofdpagina onderdelen.",
|
|
||||||
"disable_haptic_feedback": "Haptische feedback uitschakelen",
|
|
||||||
"default_quality": "Standaard kwaliteit"
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads",
|
|
||||||
"download_method": "Download methode",
|
|
||||||
"remux_max_download": "Remux max download",
|
|
||||||
"auto_download": "Auto download",
|
|
||||||
"optimized_versions_server": "Geoptimaliseerde server versies",
|
|
||||||
"save_button": "Opslaan",
|
|
||||||
"optimized_server": "Geoptimailseerde Server",
|
|
||||||
"optimized": "Geoptimaliseerd",
|
|
||||||
"default": "Standaard",
|
|
||||||
"optimized_version_hint": "Vul de URL van de optimalisatieserver in. De URL moet http of https bevatten en eventueel de poort.",
|
|
||||||
"read_more_about_optimized_server": "Lees meer over de optimalisatieserver.",
|
|
||||||
"url":"URL",
|
|
||||||
"server_url_placeholder": "http(s)://domein.org:poort"
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"plugins_title": "Plugins",
|
|
||||||
"jellyseerr": {
|
|
||||||
"jellyseerr_warning": "Deze integratie is nog in een vroeg stadium. Verwacht dat zaken nog veranderen.",
|
|
||||||
"server_url": "Server URL",
|
|
||||||
"server_url_hint": "Voorbeeld: http(s)://je-host.url\n(indien nodig: voeg de poort toe)",
|
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
|
||||||
"password": "Wachtwoord",
|
|
||||||
"password_placeholder": "Voeg het wachtwoord in voor de Jellyfin gebruiker {{username}}",
|
|
||||||
"save_button": "Opslaan",
|
|
||||||
"clear_button": "Wissen",
|
|
||||||
"login_button": "Aannmelden",
|
|
||||||
"total_media_requests": "Totaal aantal mediaverzoeken",
|
|
||||||
"movie_quota_limit": "Limiet filmquota",
|
|
||||||
"movie_quota_days": "Filmquota dagen",
|
|
||||||
"tv_quota_limit": "Limiet serie quota",
|
|
||||||
"tv_quota_days": "Serie Quota dagen",
|
|
||||||
"reset_jellyseerr_config_button": "Jellyseerr opnieuw instellen",
|
|
||||||
"unlimited": "Ongelimiteerd"
|
|
||||||
},
|
|
||||||
"marlin_search": {
|
|
||||||
"enable_marlin_search": "Marlin Search inschakeln ",
|
|
||||||
"url": "URL",
|
|
||||||
"server_url_placeholder": "http(s)://domein.org:poort",
|
|
||||||
"marlin_search_hint": "Vul de URL van de Marlin Search server in. De URL moet http of https bevatten en eventueel de poort.",
|
|
||||||
"read_more_about_marlin": "Lees meer over Marlin.",
|
|
||||||
"save_button": "Opslaan",
|
|
||||||
"toasts": {
|
|
||||||
"saved": "Opgeslagen"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"storage_title": "Opslag",
|
|
||||||
"app_usage": "App {{usedSpace}}%",
|
|
||||||
"device_usage": "Toestel {{availableSpace}}%",
|
|
||||||
"size_used": "{{used}} van {{total}} gebruikt",
|
|
||||||
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden"
|
|
||||||
},
|
|
||||||
"intro": {
|
|
||||||
"show_intro": "Toon intro",
|
|
||||||
"reset_intro": "intro opnieuw instellen"
|
|
||||||
},
|
|
||||||
"logs": {
|
|
||||||
"logs_title": "Logs",
|
|
||||||
"no_logs_available": "Geen logs beschikbaar",
|
|
||||||
"delete_all_logs": "Verwijder alle logs"
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"title": "Talen",
|
|
||||||
"app_language": "App taal",
|
|
||||||
"app_language_description": "Selecteer een taal voor de app.",
|
|
||||||
"system": "Systeem"
|
|
||||||
},
|
|
||||||
"toasts":{
|
|
||||||
"error_deleting_files": "Fout bij het verwijden van bestanden",
|
|
||||||
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
|
|
||||||
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld",
|
|
||||||
"connected": "Verbonden",
|
|
||||||
"could_not_connect": "Kon niet verbinden",
|
|
||||||
"invalid_url": "Ongeldige URL"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "Downloads",
|
|
||||||
"tvseries": "Series",
|
|
||||||
"movies": "Films",
|
|
||||||
"queue": "Wachtrij",
|
|
||||||
"queue_hint": "Wachtrij en downloads verdwijnen bij een herstart van de app",
|
|
||||||
"no_items_in_queue": "Geen items in wachtrij",
|
|
||||||
"no_downloaded_items": "Geen gedownloade items",
|
|
||||||
"delete_all_movies_button": "Verwijder alle films",
|
|
||||||
"delete_all_tvseries_button": "Verwijder alle Series",
|
|
||||||
"delete_all_button": "Verwijder alles",
|
|
||||||
"active_download": "Actieve download",
|
|
||||||
"no_active_downloads": "Geen actieve downloads",
|
|
||||||
"active_downloads": "Actieve downloads",
|
|
||||||
"new_app_version_requires_re_download": "Nieuwe app-versie vereist opnieuw downloaden",
|
|
||||||
"new_app_version_requires_re_download_description": "Voor de nieuwe update moet de content opnieuw worden gedownload. Verwijder alle gedownloade content en probeer het opnieuw.",
|
|
||||||
"back": "Terug",
|
|
||||||
"delete": "Verwijder",
|
|
||||||
"something_went_wrong": "Er ging iets mis",
|
|
||||||
"could_not_get_stream_url_from_jellyfin": "Kon de URL van de stream niet krijgen van Jellyfin",
|
|
||||||
"eta": "ETA {{eta}}",
|
|
||||||
"methods": "Methoden",
|
|
||||||
"toasts": {
|
|
||||||
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
|
|
||||||
"deleted_all_movies_successfully": "Alle filns succesvol verwijderd!",
|
|
||||||
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
|
|
||||||
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
|
|
||||||
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
|
|
||||||
"download_cancelled": "Download geannuleerd",
|
|
||||||
"could_not_cancel_download": "Kon de download niet annuleren",
|
|
||||||
"download_completed": "Download afgerond",
|
|
||||||
"download_started_for": "Download gestart voor {{item}}",
|
|
||||||
"item_is_ready_to_be_downloaded": "{{item}} is klaar op te downloaden",
|
|
||||||
"download_stated_for_item": "Download gestart voor {{item}}",
|
|
||||||
"download_failed_for_item": "Download gefaald voor {{item}} - {{error}}",
|
|
||||||
"download_completed_for_item": "Download afgerond voor {{item}}",
|
|
||||||
"queued_item_for_optimization": "{{item}} in de wachtrij gezet voor optimalisatie",
|
|
||||||
"failed_to_start_download_for_item": "Kon de download voor {{item}} niet starten: {{message}}",
|
|
||||||
"server_responded_with_status_code": "Server heeft geantwoord met {{statusCode}}",
|
|
||||||
"no_response_received_from_server": "Geen antwoord gekregen van de server",
|
|
||||||
"error_setting_up_the_request": "Fout bij het opstellen van de aanvraag",
|
|
||||||
"failed_to_start_download_for_item_unexpected_error": "Kon de download voor {{item}} niet starten: Onverwachte fout",
|
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "Alle bestanden, mappen en taken succesvol verwijderd",
|
|
||||||
"an_error_occured_while_deleting_files_and_jobs": "Er is een fout opgetreden tijdens het verwijderen van bestanden en taken",
|
|
||||||
"go_to_downloads": "Ga naar downloads"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"search": {
|
|
||||||
"search_here": "Zoek hier...",
|
|
||||||
"search": "Zoek...",
|
|
||||||
"x_items": "{{count}} items",
|
|
||||||
"library": "Bibliotheek",
|
|
||||||
"discover": "Ontdek",
|
|
||||||
"no_results": "Geen resultaten",
|
|
||||||
"no_results_found_for": "Geen resultaten gevonden voor",
|
|
||||||
"movies": "Films",
|
|
||||||
"series": "Series",
|
|
||||||
"episodes": "Afleveringen",
|
|
||||||
"collections": "Collecties",
|
|
||||||
"actors": "Acteurs",
|
|
||||||
"request_movies": "Vraag films aan",
|
|
||||||
"request_series": "Vraag series aan",
|
|
||||||
"recently_added": "Recent Toegevoegd",
|
|
||||||
"recent_requests": "Recent Aangevraagd",
|
|
||||||
"plex_watchlist": "Plex Kijklijst",
|
|
||||||
"trending": "Trending",
|
|
||||||
"popular_movies": "Populaire Films",
|
|
||||||
"movie_genres": "Film Genres",
|
|
||||||
"upcoming_movies": "Aankomende Movies",
|
|
||||||
"studios": "Studios",
|
|
||||||
"popular_tv": "Populaire TV",
|
|
||||||
"tv_genres": "TV Genres",
|
|
||||||
"upcoming_tv": "Opkomend TV",
|
|
||||||
"networks": "Netwerken",
|
|
||||||
"tmdb_movie_keyword": "TMDB Film Trefwoord",
|
|
||||||
"tmdb_movie_genre": "TMDB Film Genre",
|
|
||||||
"tmdb_tv_keyword": "TMDB TV Trefwoord",
|
|
||||||
"tmdb_tv_genre": "TMDB TV Genre",
|
|
||||||
"tmdb_search": "TMDB Zoeken",
|
|
||||||
"tmdb_studio": "TMDB Studio",
|
|
||||||
"tmdb_network": "TMDB Netwerk",
|
|
||||||
"tmdb_movie_streaming_services": "TMDB Film Streaming Diensten",
|
|
||||||
"tmdb_tv_streaming_services": "TMDB TV Streaming Diensten"
|
|
||||||
},
|
|
||||||
"library": {
|
|
||||||
"no_items_found": "Geen items gevonden",
|
|
||||||
"no_results": "Geen resultaten",
|
|
||||||
"no_libraries_found": "Geen bibliotheken gevonden",
|
|
||||||
"item_types": {
|
|
||||||
"movies": "films",
|
|
||||||
"series": "series",
|
|
||||||
"boxsets": "box sets",
|
|
||||||
"items": "items"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"display": "Weergave",
|
|
||||||
"row": "Rij",
|
|
||||||
"list": "Lijst",
|
|
||||||
"image_style": "Stijl van afbeelding",
|
|
||||||
"poster": "Poster",
|
|
||||||
"cover": "Cover",
|
|
||||||
"show_titles": "Toon titels",
|
|
||||||
"show_stats": "Toon statistieken"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"genres": "Genres",
|
|
||||||
"years": "Jaren",
|
|
||||||
"sort_by": "Sorteren op",
|
|
||||||
"sort_order": "Sorteer volgorde",
|
|
||||||
"tags": "Labels"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"favorites": {
|
|
||||||
"series": "Series",
|
|
||||||
"movies": "Films",
|
|
||||||
"episodes": "Afleveringen",
|
|
||||||
"videos": "Videos",
|
|
||||||
"boxsets": "Boxsets",
|
|
||||||
"playlists": "Afspeellijsten"
|
|
||||||
},
|
|
||||||
"custom_links": {
|
|
||||||
"no_links": "Geen links"
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"error": "Fout",
|
|
||||||
"failed_to_get_stream_url": "De stream-URL kon niet worden verkregen",
|
|
||||||
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
|
|
||||||
"client_error": "Fout van de client",
|
|
||||||
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
|
|
||||||
"message_from_server": "Bericht van de server: {{message}}",
|
|
||||||
"video_has_finished_playing": "Video is gedaan met spelen!",
|
|
||||||
"no_video_source": "Geen video bron...",
|
|
||||||
"next_episode": "Volgende Aflevering",
|
|
||||||
"refresh_tracks": "Tracks verversen",
|
|
||||||
"subtitle_tracks": "Ondertitel Tracks:",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
|
||||||
"playback_state": "Afspeelstatus:",
|
|
||||||
"no_data_available": "Geen data beschikbaar",
|
|
||||||
"index": "Index:"
|
|
||||||
},
|
|
||||||
"item_card": {
|
|
||||||
"next_up": "Volgende",
|
|
||||||
"no_items_to_display": "Geen items om te tonen",
|
|
||||||
"cast_and_crew": "Cast & Crew",
|
|
||||||
"series": "Series",
|
|
||||||
"seasons": "Seizoenen",
|
|
||||||
"season": "Seizoen",
|
|
||||||
"no_episodes_for_this_season": "Geen afleveringen voor dit seizoen",
|
|
||||||
"overview": "Overzicht",
|
|
||||||
"more_with": "Meer met {{name}}",
|
|
||||||
"similar_items": "Gelijkaardige items",
|
|
||||||
"no_similar_items_found": "Geen gelijkaardige items gevonden",
|
|
||||||
"video": "Video",
|
|
||||||
"more_details": "Meer details",
|
|
||||||
"quality": "Kwaliteit",
|
|
||||||
"audio": "Audio",
|
|
||||||
"subtitles": "Ondertitel",
|
|
||||||
"show_more": "Toon meer",
|
|
||||||
"show_less": "Toon minden",
|
|
||||||
"appeared_in": "Verschenen in",
|
|
||||||
"could_not_load_item": "Kon item niet laden",
|
|
||||||
"none": "Geen",
|
|
||||||
"download": {
|
|
||||||
"download_season": "Download Seizoen",
|
|
||||||
"download_series": "Download Serie",
|
|
||||||
"download_episode": "Download Aflevering",
|
|
||||||
"download_movie": "Download Film",
|
|
||||||
"download_x_item": "Download {{item_count}} items",
|
|
||||||
"download_button": "Download",
|
|
||||||
"using_optimized_server": "Geoptimaliseerde server gebruiken",
|
|
||||||
"using_default_method": "Standaard methode gebruiken"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"live_tv": {
|
|
||||||
"next": "Volgende ",
|
|
||||||
"previous": "Vorige",
|
|
||||||
"live_tv": "Live TV",
|
|
||||||
"coming_soon": "Binnenkort beschikbaar",
|
|
||||||
"on_now": "Nu op",
|
|
||||||
"shows": "Shows",
|
|
||||||
"movies": "Films",
|
|
||||||
"sports": "Sport",
|
|
||||||
"for_kids": "Voor kinderen",
|
|
||||||
"news": "Nieuws"
|
|
||||||
},
|
|
||||||
"jellyseerr":{
|
|
||||||
"confirm": "Bevestig",
|
|
||||||
"cancel": "Annuleer",
|
|
||||||
"yes": "Ja",
|
|
||||||
"whats_wrong": "Wat is er mis?",
|
|
||||||
"issue_type": "Type probleem",
|
|
||||||
"select_an_issue": "Selecteer een probleem",
|
|
||||||
"types": "Types",
|
|
||||||
"describe_the_issue": "(optioneel) beschrijf het probleem...",
|
|
||||||
"submit_button": "Verzenden",
|
|
||||||
"report_issue_button": "Meld een probleem",
|
|
||||||
"request_button": "Aanvragen",
|
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Ben je zeker dat je alle seizoenen wil aanvragen?",
|
|
||||||
"failed_to_login": "Kon niet aanmelden",
|
|
||||||
"cast": "Cast",
|
|
||||||
"details": "Details",
|
|
||||||
"status": "Status",
|
|
||||||
"original_title": "Originele titel",
|
|
||||||
"series_type": "Serie Type",
|
|
||||||
"release_dates": "Verschijningsdatums",
|
|
||||||
"first_air_date": "Eerste uitzenddatum",
|
|
||||||
"next_air_date": "Volgende uitzenddatum",
|
|
||||||
"revenue": "Inkomsten",
|
|
||||||
"budget": "Budget",
|
|
||||||
"original_language": "Originele taal",
|
|
||||||
"production_country": "Land van productie",
|
|
||||||
"studios": "Studio",
|
|
||||||
"network": "Netwerk",
|
|
||||||
"currently_streaming_on": "Momenteel te streamen op",
|
|
||||||
"advanced": "Geavanceerd",
|
|
||||||
"request_as": "Vraag aan als",
|
|
||||||
"tags": "Labels",
|
|
||||||
"quality_profile": "Kwaliteitsprofiel",
|
|
||||||
"root_folder": "Hoofdmap",
|
|
||||||
"season_x": "Seizoen {{seasons}}",
|
|
||||||
"season_number": "Seizoen {{season_number}}",
|
|
||||||
"number_episodes": "{{episode_number}} Afleveringen",
|
|
||||||
"born": "Geboren",
|
|
||||||
"appearances": "Verschijningen",
|
|
||||||
"toasts": {
|
|
||||||
"jellyseer_does_not_meet_requirements": "Jellyseerr server voldoet niet aan de minimale versievereisten! Update naar minimaal 2.0.0",
|
|
||||||
"jellyseerr_test_failed": "Jellyseerr test gefaald. Probeer opnieuw.",
|
|
||||||
"failed_to_test_jellyseerr_server_url": "Mislukt bij het testen van jellyseerr server url",
|
|
||||||
"issue_submitted": "Probleem ingediend!",
|
|
||||||
"requested_item": "{{item}} aangevraagd!",
|
|
||||||
"you_dont_have_permission_to_request": "Je hebt geen toestemming om aanvragen te doen!",
|
|
||||||
"something_went_wrong_requesting_media": "Er ging iets iets mis met het aavragen van media!"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"home": "Thuis",
|
|
||||||
"search": "Zoeken",
|
|
||||||
"library": "Bibliotheek",
|
|
||||||
"custom_links": "Aangepaste links",
|
|
||||||
"favorites": "Favorieten"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
{
|
|
||||||
"login": {
|
|
||||||
"username_required": "需要用戶名",
|
|
||||||
"error_title": "錯誤",
|
|
||||||
"login_title": "登入",
|
|
||||||
"login_to_title": "登入至",
|
|
||||||
"username_placeholder": "用戶名",
|
|
||||||
"password_placeholder": "密碼",
|
|
||||||
"login_button": "登入",
|
|
||||||
"quick_connect": "快速連接",
|
|
||||||
"enter_code_to_login": "輸入代碼 {{code}} 以登入",
|
|
||||||
"failed_to_initiate_quick_connect": "無法啟動快速連接",
|
|
||||||
"got_it": "知道了",
|
|
||||||
"connection_failed": "連接失敗",
|
|
||||||
"could_not_connect_to_server": "無法連接到伺服器。請檢查 URL 和您的網絡連接。",
|
|
||||||
"an_unexpected_error_occured": "發生意外錯誤",
|
|
||||||
"change_server": "更改伺服器",
|
|
||||||
"invalid_username_or_password": "無效的用戶名或密碼",
|
|
||||||
"user_does_not_have_permission_to_log_in": "用戶無權登入",
|
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "伺服器響應時間過長,請稍後再試",
|
|
||||||
"server_received_too_many_requests_try_again_later": "伺服器收到太多請求,請稍後再試。",
|
|
||||||
"there_is_a_server_error": "伺服器出錯",
|
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "發生意外錯誤。您是否正確輸入了伺服器 URL?"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"enter_url_to_jellyfin_server": "輸入您的 Jellyfin 伺服器的 URL",
|
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
|
||||||
"connect_button": "連接",
|
|
||||||
"previous_servers": "先前的伺服器",
|
|
||||||
"clear_button": "清除",
|
|
||||||
"search_for_local_servers": "搜尋本地伺服器",
|
|
||||||
"searching": "搜尋中...",
|
|
||||||
"servers": "伺服器"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"no_internet": "無網絡",
|
|
||||||
"no_items": "無項目",
|
|
||||||
"no_internet_message": "別擔心,您仍然可以觀看\n已下載的內容。",
|
|
||||||
"go_to_downloads": "前往下載",
|
|
||||||
"oops": "哎呀!",
|
|
||||||
"error_message": "出錯了。\n請重新登出並登入。",
|
|
||||||
"continue_watching": "繼續觀看",
|
|
||||||
"next_up": "下一個",
|
|
||||||
"recently_added_in": "最近添加於 {{libraryName}}",
|
|
||||||
"suggested_movies": "推薦電影",
|
|
||||||
"suggested_episodes": "推薦劇集",
|
|
||||||
"intro": {
|
|
||||||
"welcome_to_streamyfin": "歡迎來到 Streamyfin",
|
|
||||||
"a_free_and_open_source_client_for_jellyfin": "一個免費且開源的 Jellyfin 客戶端。",
|
|
||||||
"features_title": "功能",
|
|
||||||
"features_description": "Streamyfin 擁有許多功能,並與多種軟體整合,您可以在設置菜單中找到這些功能,包括:",
|
|
||||||
"jellyseerr_feature_description": "連接到您的 Jellyseerr 實例並直接在應用程序中請求電影。",
|
|
||||||
"downloads_feature_title": "下載",
|
|
||||||
"downloads_feature_description": "下載電影和電視節目以離線觀看。使用默認方法或安裝 Optimized Server 以在背景中下載文件。",
|
|
||||||
"chromecast_feature_description": "將電影和電視節目投射到您的 Chromecast 設備。",
|
|
||||||
"centralised_settings_plugin_title": "統一設置插件",
|
|
||||||
"centralised_settings_plugin_description": "從 Jellyfin 伺服器上的統一位置改變設置。所有用戶的所有客戶端設置將會自動同步。",
|
|
||||||
"done_button": "完成",
|
|
||||||
"go_to_settings_button": "前往設置",
|
|
||||||
"read_more": "閱讀更多"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"settings_title": "設置",
|
|
||||||
"log_out_button": "登出",
|
|
||||||
"user_info": {
|
|
||||||
"user_info_title": "用戶信息",
|
|
||||||
"user": "用戶",
|
|
||||||
"server": "伺服器",
|
|
||||||
"token": "令牌",
|
|
||||||
"app_version": "應用版本"
|
|
||||||
},
|
|
||||||
"quick_connect": {
|
|
||||||
"quick_connect_title": "快速連接",
|
|
||||||
"authorize_button": "授權快速連接",
|
|
||||||
"enter_the_quick_connect_code": "輸入快速連接代碼...",
|
|
||||||
"success": "成功",
|
|
||||||
"quick_connect_autorized": "快速連接已授權",
|
|
||||||
"error": "錯誤",
|
|
||||||
"invalid_code": "無效代碼",
|
|
||||||
"authorize": "授權"
|
|
||||||
},
|
|
||||||
"media_controls": {
|
|
||||||
"media_controls_title": "媒體控制",
|
|
||||||
"forward_skip_length": "前進跳過長度",
|
|
||||||
"rewind_length": "倒帶長度",
|
|
||||||
"seconds_unit": "秒"
|
|
||||||
},
|
|
||||||
"audio": {
|
|
||||||
"audio_title": "音頻",
|
|
||||||
"set_audio_track": "從上一個項目設置音軌",
|
|
||||||
"audio_language": "音頻語言",
|
|
||||||
"audio_hint": "選擇默認音頻語言。",
|
|
||||||
"none": "無",
|
|
||||||
"language": "語言"
|
|
||||||
},
|
|
||||||
"subtitles": {
|
|
||||||
"subtitle_title": "字幕",
|
|
||||||
"subtitle_language": "字幕語言",
|
|
||||||
"subtitle_mode": "字幕模式",
|
|
||||||
"set_subtitle_track": "從上一個項目設置字幕軌道",
|
|
||||||
"subtitle_size": "字幕大小",
|
|
||||||
"subtitle_hint": "配置字幕偏好。",
|
|
||||||
"none": "無",
|
|
||||||
"language": "語言",
|
|
||||||
"loading": "加載中",
|
|
||||||
"modes": {
|
|
||||||
"Default": "默認",
|
|
||||||
"Smart": "智能",
|
|
||||||
"Always": "總是",
|
|
||||||
"None": "無",
|
|
||||||
"OnlyForced": "僅強制"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"other_title": "其他",
|
|
||||||
"auto_rotate": "自動旋轉",
|
|
||||||
"video_orientation": "影片方向",
|
|
||||||
"orientation": "方向",
|
|
||||||
"orientations": {
|
|
||||||
"DEFAULT": "默認",
|
|
||||||
"ALL": "全部",
|
|
||||||
"PORTRAIT": "縱向",
|
|
||||||
"PORTRAIT_UP": "縱向向上",
|
|
||||||
"PORTRAIT_DOWN": "縱向向下",
|
|
||||||
"LANDSCAPE": "橫向",
|
|
||||||
"LANDSCAPE_LEFT": "橫向左",
|
|
||||||
"LANDSCAPE_RIGHT": "橫向右",
|
|
||||||
"OTHER": "其他",
|
|
||||||
"UNKNOWN": "未知"
|
|
||||||
},
|
|
||||||
"safe_area_in_controls": "控制中的安全區域",
|
|
||||||
"show_custom_menu_links": "顯示自定義菜單鏈接",
|
|
||||||
"hide_libraries": "隱藏媒體庫",
|
|
||||||
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
|
|
||||||
"disable_haptic_feedback": "禁用觸覺回饋"
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "下載",
|
|
||||||
"download_method": "下載方法",
|
|
||||||
"remux_max_download": "Remux 最大下載",
|
|
||||||
"auto_download": "自動下載",
|
|
||||||
"optimized_versions_server": "Optimized Version 伺服器",
|
|
||||||
"save_button": "保存",
|
|
||||||
"optimized_server": "Optimized Server",
|
|
||||||
"optimized": "優化",
|
|
||||||
"default": "默認",
|
|
||||||
"optimized_version_hint": "輸入 Optimized Server 的 URL。URL 應包括 http(s) 和端口 (可選)。",
|
|
||||||
"read_more_about_optimized_server": "閱讀更多關於 Optimized Server 的信息。",
|
|
||||||
"url": "URL",
|
|
||||||
"server_url_placeholder": "http(s)://domain.org:port"
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"plugins_title": "插件",
|
|
||||||
"jellyseerr": {
|
|
||||||
"jellyseerr_warning": "此集成處於早期階段。功能可能會有變化。",
|
|
||||||
"server_url": "伺服器 URL",
|
|
||||||
"server_url_hint": "示例:http(s)://your-host.url\n(如果需要,添加端口)",
|
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
|
||||||
"password": "密碼",
|
|
||||||
"password_placeholder": "輸入 Jellyfin 用戶 {{username}} 的密碼",
|
|
||||||
"save_button": "保存",
|
|
||||||
"clear_button": "清除",
|
|
||||||
"login_button": "登入",
|
|
||||||
"total_media_requests": "總媒體請求",
|
|
||||||
"movie_quota_limit": "電影配額限制",
|
|
||||||
"movie_quota_days": "電影配額天數",
|
|
||||||
"tv_quota_limit": "電視配額限制",
|
|
||||||
"tv_quota_days": "電視配額天數",
|
|
||||||
"reset_jellyseerr_config_button": "重置 Jellyseerr 配置",
|
|
||||||
"unlimited": "無限制"
|
|
||||||
},
|
|
||||||
"marlin_search": {
|
|
||||||
"enable_marlin_search": "啟用 Marlin 搜索",
|
|
||||||
"url": "URL",
|
|
||||||
"server_url_placeholder": "http(s)://domain.org:port",
|
|
||||||
"marlin_search_hint": "輸入 Marlin 伺服器的 URL。URL 應包括 http(s) 和端口 (可選)。",
|
|
||||||
"read_more_about_marlin": "閱讀更多關於 Marlin 的信息。",
|
|
||||||
"save_button": "保存",
|
|
||||||
"toasts": {
|
|
||||||
"saved": "已保存"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"storage_title": "存儲",
|
|
||||||
"app_usage": "應用 {{usedSpace}}%",
|
|
||||||
"device_usage": "設備 {{availableSpace}}%",
|
|
||||||
"size_used": "已使用 {{used}} / {{total}}",
|
|
||||||
"delete_all_downloaded_files": "刪除所有已下載文件"
|
|
||||||
},
|
|
||||||
"intro": {
|
|
||||||
"show_intro": "顯示介紹",
|
|
||||||
"reset_intro": "重置介紹"
|
|
||||||
},
|
|
||||||
"logs": {
|
|
||||||
"logs_title": "日誌",
|
|
||||||
"no_logs_available": "無可用日誌",
|
|
||||||
"delete_all_logs": "刪除所有日誌"
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"title": "語言",
|
|
||||||
"app_language": "應用語言",
|
|
||||||
"app_language_description": "選擇應用的語言。",
|
|
||||||
"system": "系統"
|
|
||||||
},
|
|
||||||
"toasts": {
|
|
||||||
"error_deleting_files": "刪除文件時出錯",
|
|
||||||
"background_downloads_enabled": "背景下載已啟用",
|
|
||||||
"background_downloads_disabled": "背景下載已禁用",
|
|
||||||
"connected": "已連接",
|
|
||||||
"could_not_connect": "無法連接",
|
|
||||||
"invalid_url": "無效的 URL"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "下載",
|
|
||||||
"tvseries": "電視劇",
|
|
||||||
"movies": "電影",
|
|
||||||
"queue": "隊列",
|
|
||||||
"queue_hint": "應用重啟後隊列和下載將會丟失",
|
|
||||||
"no_items_in_queue": "隊列中無項目",
|
|
||||||
"no_downloaded_items": "無已下載項目",
|
|
||||||
"delete_all_movies_button": "刪除所有電影",
|
|
||||||
"delete_all_tvseries_button": "刪除所有電視劇",
|
|
||||||
"delete_all_button": "刪除全部",
|
|
||||||
"active_download": "活動下載",
|
|
||||||
"no_active_downloads": "無活動下載",
|
|
||||||
"active_downloads": "活動下載",
|
|
||||||
"new_app_version_requires_re_download": "新應用版本需要重新下載",
|
|
||||||
"new_app_version_requires_re_download_description": "新更新需要重新下載內容。請刪除所有已下載內容後再重試。",
|
|
||||||
"back": "返回",
|
|
||||||
"delete": "刪除",
|
|
||||||
"something_went_wrong": "出了些問題",
|
|
||||||
"could_not_get_stream_url_from_jellyfin": "無法從 Jellyfin 獲取串流 URL",
|
|
||||||
"eta": "預計完成時間 {{eta}}",
|
|
||||||
"methods": "方法",
|
|
||||||
"toasts": {
|
|
||||||
"you_are_not_allowed_to_download_files": "您無權下載文件。",
|
|
||||||
"deleted_all_movies_successfully": "成功刪除所有電影!",
|
|
||||||
"failed_to_delete_all_movies": "刪除所有電影失敗",
|
|
||||||
"deleted_all_tvseries_successfully": "成功刪除所有電視劇!",
|
|
||||||
"failed_to_delete_all_tvseries": "刪除所有電視劇失敗",
|
|
||||||
"download_cancelled": "下載已取消",
|
|
||||||
"could_not_cancel_download": "無法取消下載",
|
|
||||||
"download_completed": "下載完成",
|
|
||||||
"download_started_for": "開始下載 {{item}}",
|
|
||||||
"item_is_ready_to_be_downloaded": "{{item}} 準備好下載",
|
|
||||||
"download_stated_for_item": "開始下載 {{item}}",
|
|
||||||
"download_failed_for_item": "下載失敗 {{item}} - {{error}}",
|
|
||||||
"download_completed_for_item": "下載完成 {{item}}",
|
|
||||||
"queued_item_for_optimization": "已將 {{item}} 排隊進行優化",
|
|
||||||
"failed_to_start_download_for_item": "無法開始下載 {{item}}: {{message}}",
|
|
||||||
"server_responded_with_status_code": "伺服器響應狀態 {{statusCode}}",
|
|
||||||
"no_response_received_from_server": "未收到伺服器的響應",
|
|
||||||
"error_setting_up_the_request": "設置請求時出錯",
|
|
||||||
"failed_to_start_download_for_item_unexpected_error": "無法開始下載 {{item}}: 發生意外錯誤",
|
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "所有文件、文件夾和任務成功刪除",
|
|
||||||
"an_error_occured_while_deleting_files_and_jobs": "刪除文件和任務時發生錯誤",
|
|
||||||
"go_to_downloads": "前往下載"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"search": {
|
|
||||||
"search_here": "在這裡搜索...",
|
|
||||||
"search": "搜索...",
|
|
||||||
"x_items": "{{count}} 項目",
|
|
||||||
"library": "媒體庫",
|
|
||||||
"discover": "發現",
|
|
||||||
"no_results": "沒有結果",
|
|
||||||
"no_results_found_for": "未找到結果",
|
|
||||||
"movies": "電影",
|
|
||||||
"series": "系列",
|
|
||||||
"episodes": "劇集",
|
|
||||||
"collections": "收藏",
|
|
||||||
"actors": "演員",
|
|
||||||
"request_movies": "請求電影",
|
|
||||||
"request_series": "請求系列",
|
|
||||||
"recently_added": "最近添加",
|
|
||||||
"recent_requests": "最近請求",
|
|
||||||
"plex_watchlist": "Plex 觀影清單",
|
|
||||||
"trending": "趨勢",
|
|
||||||
"popular_movies": "熱門電影",
|
|
||||||
"movie_genres": "電影類型",
|
|
||||||
"upcoming_movies": "即將上映的電影",
|
|
||||||
"studios": "工作室",
|
|
||||||
"popular_tv": "熱門電視",
|
|
||||||
"tv_genres": "電視類型",
|
|
||||||
"upcoming_tv": "即將上映的電視",
|
|
||||||
"networks": "網絡",
|
|
||||||
"tmdb_movie_keyword": "TMDB 電影關鍵詞",
|
|
||||||
"tmdb_movie_genre": "TMDB 電影類型",
|
|
||||||
"tmdb_tv_keyword": "TMDB 電視關鍵詞",
|
|
||||||
"tmdb_tv_genre": "TMDB 電視類型",
|
|
||||||
"tmdb_search": "TMDB 搜索",
|
|
||||||
"tmdb_studio": "TMDB 工作室",
|
|
||||||
"tmdb_network": "TMDB 網絡",
|
|
||||||
"tmdb_movie_streaming_services": "TMDB 電影流媒體服務",
|
|
||||||
"tmdb_tv_streaming_services": "TMDB 電視流媒體服務"
|
|
||||||
},
|
|
||||||
"library": {
|
|
||||||
"no_items_found": "未找到項目",
|
|
||||||
"no_results": "沒有結果",
|
|
||||||
"no_libraries_found": "未找到媒體庫",
|
|
||||||
"item_types": {
|
|
||||||
"movies": "電影",
|
|
||||||
"series": "系列",
|
|
||||||
"boxsets": "套裝",
|
|
||||||
"items": "項目"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"display": "顯示",
|
|
||||||
"row": "行",
|
|
||||||
"list": "列表",
|
|
||||||
"image_style": "圖片樣式",
|
|
||||||
"poster": "海報",
|
|
||||||
"cover": "封面",
|
|
||||||
"show_titles": "顯示標題",
|
|
||||||
"show_stats": "顯示統計"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"genres": "類型",
|
|
||||||
"years": "年份",
|
|
||||||
"sort_by": "排序依據",
|
|
||||||
"sort_order": "排序順序",
|
|
||||||
"tags": "標籤"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"favorites": {
|
|
||||||
"series": "系列",
|
|
||||||
"movies": "電影",
|
|
||||||
"episodes": "劇集",
|
|
||||||
"videos": "影片",
|
|
||||||
"boxsets": "套裝",
|
|
||||||
"playlists": "播放列表"
|
|
||||||
},
|
|
||||||
"custom_links": {
|
|
||||||
"no_links": "無鏈接"
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"error": "錯誤",
|
|
||||||
"failed_to_get_stream_url": "無法獲取流 URL",
|
|
||||||
"an_error_occured_while_playing_the_video": "播放影片時發生錯誤。請檢查設置中的日誌。",
|
|
||||||
"client_error": "客戶端錯誤",
|
|
||||||
"could_not_create_stream_for_chromecast": "無法為 Chromecast 建立串流",
|
|
||||||
"message_from_server": "來自伺服器的消息:{{message}}",
|
|
||||||
"video_has_finished_playing": "影片播放完畢!",
|
|
||||||
"no_video_source": "無影片來源...",
|
|
||||||
"next_episode": "下一集",
|
|
||||||
"refresh_tracks": "刷新軌道",
|
|
||||||
"subtitle_tracks": "字幕軌道:",
|
|
||||||
"audio_tracks": "音頻軌道:",
|
|
||||||
"playback_state": "播放狀態:",
|
|
||||||
"no_data_available": "無可用數據",
|
|
||||||
"index": "索引:"
|
|
||||||
},
|
|
||||||
"item_card": {
|
|
||||||
"next_up": "下一個",
|
|
||||||
"no_items_to_display": "無項目顯示",
|
|
||||||
"cast_and_crew": "演員和工作人員",
|
|
||||||
"series": "系列",
|
|
||||||
"seasons": "季",
|
|
||||||
"season": "季",
|
|
||||||
"no_episodes_for_this_season": "本季無劇集",
|
|
||||||
"overview": "概覽",
|
|
||||||
"more_with": "更多 {{name}} 的作品",
|
|
||||||
"similar_items": "類似項目",
|
|
||||||
"no_similar_items_found": "未找到類似項目",
|
|
||||||
"video": "影片",
|
|
||||||
"more_details": "更多詳情",
|
|
||||||
"quality": "質量",
|
|
||||||
"audio": "音頻",
|
|
||||||
"subtitles": "字幕",
|
|
||||||
"show_more": "顯示更多",
|
|
||||||
"show_less": "顯示更少",
|
|
||||||
"appeared_in": "出現於",
|
|
||||||
"could_not_load_item": "無法加載項目",
|
|
||||||
"none": "無",
|
|
||||||
"download": {
|
|
||||||
"download_season": "下載季度",
|
|
||||||
"download_series": "下載系列",
|
|
||||||
"download_episode": "下載劇集",
|
|
||||||
"download_movie": "下載電影",
|
|
||||||
"download_x_item": "下載 {{item_count}} 項目",
|
|
||||||
"download_button": "下載",
|
|
||||||
"using_optimized_server": "使用 Optimized Server",
|
|
||||||
"using_default_method": "使用默認方法"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"live_tv": {
|
|
||||||
"next": "下一個",
|
|
||||||
"previous": "上一個",
|
|
||||||
"live_tv": "直播電視",
|
|
||||||
"coming_soon": "即將推出",
|
|
||||||
"on_now": "正在播放",
|
|
||||||
"shows": "節目",
|
|
||||||
"movies": "電影",
|
|
||||||
"sports": "體育",
|
|
||||||
"for_kids": "兒童",
|
|
||||||
"news": "新聞"
|
|
||||||
},
|
|
||||||
"jellyseerr": {
|
|
||||||
"confirm": "確認",
|
|
||||||
"cancel": "取消",
|
|
||||||
"yes": "是",
|
|
||||||
"whats_wrong": "出了什麼問題?",
|
|
||||||
"issue_type": "問題類型",
|
|
||||||
"select_an_issue": "選擇一個問題",
|
|
||||||
"types": "類型",
|
|
||||||
"describe_the_issue": "(可選)描述問題...",
|
|
||||||
"submit_button": "提交",
|
|
||||||
"report_issue_button": "報告問題",
|
|
||||||
"request_button": "請求",
|
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "您確定要請求所有季度的節目嗎?",
|
|
||||||
"failed_to_login": "登入失敗",
|
|
||||||
"cast": "演員",
|
|
||||||
"details": "詳情",
|
|
||||||
"status": "狀態",
|
|
||||||
"original_title": "原標題",
|
|
||||||
"series_type": "系列類型",
|
|
||||||
"release_dates": "發行日期",
|
|
||||||
"first_air_date": "首次播出日期",
|
|
||||||
"next_air_date": "下次播出日期",
|
|
||||||
"revenue": "收入",
|
|
||||||
"budget": "預算",
|
|
||||||
"original_language": "原始語言",
|
|
||||||
"production_country": "製作國家",
|
|
||||||
"studios": "工作室",
|
|
||||||
"network": "網絡",
|
|
||||||
"currently_streaming_on": "目前在以下流媒體上播放",
|
|
||||||
"advanced": "高級",
|
|
||||||
"request_as": "請求為",
|
|
||||||
"tags": "標籤",
|
|
||||||
"quality_profile": "質量配置文件",
|
|
||||||
"root_folder": "根文件夾",
|
|
||||||
"season_x": "第 {{seasons}} 季",
|
|
||||||
"season_number": "第 {{season_number}} 季",
|
|
||||||
"number_episodes": "{{episode_number}} 集",
|
|
||||||
"born": "出生",
|
|
||||||
"appearances": "出場",
|
|
||||||
"toasts": {
|
|
||||||
"jellyseer_does_not_meet_requirements": "Jellyseerr 伺服器不符合最低版本要求!請更新至至少 2.0.0",
|
|
||||||
"jellyseerr_test_failed": "Jellyseerr 測試失敗。請再試一次。",
|
|
||||||
"failed_to_test_jellyseerr_server_url": "無法測試 Jellyseerr 伺服器 URL",
|
|
||||||
"issue_submitted": "問題已提交!",
|
|
||||||
"requested_item": "已請求 {{item}}!",
|
|
||||||
"you_dont_have_permission_to_request": "您無權請求媒體!",
|
|
||||||
"something_went_wrong_requesting_media": "請求媒體時出了些問題!"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"home": "主頁",
|
|
||||||
"search": "搜索",
|
|
||||||
"library": "庫",
|
|
||||||
"custom_links": "自定義鏈接",
|
|
||||||
"favorites": "收藏"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,15 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
134
utils/SubtitleHelper.ts
Normal file
134
utils/SubtitleHelper.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
ItemFilter,
|
ItemFilter,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { writeInfoLog } from "@/utils/log";
|
import { writeInfoLog } from "@/utils/log";
|
||||||
|
|
||||||
@@ -123,7 +122,6 @@ export type Settings = {
|
|||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
defaultBitrate?: Bitrate;
|
|
||||||
libraryOptions: LibraryOptions;
|
libraryOptions: LibraryOptions;
|
||||||
defaultAudioLanguage: CultureDto | null;
|
defaultAudioLanguage: CultureDto | null;
|
||||||
playDefaultAudioTrack: boolean;
|
playDefaultAudioTrack: boolean;
|
||||||
@@ -159,54 +157,53 @@ export type StreamyfinPluginConfig = {
|
|||||||
settings: PluginLockableSettings;
|
settings: PluginLockableSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValues: Settings = {
|
const loadSettings = (): Settings => {
|
||||||
home: null,
|
const defaultValues: Settings = {
|
||||||
autoRotate: true,
|
home: null,
|
||||||
forceLandscapeInVideoPlayer: false,
|
autoRotate: true,
|
||||||
deviceProfile: "Expo",
|
forceLandscapeInVideoPlayer: false,
|
||||||
mediaListCollectionIds: [],
|
deviceProfile: "Expo",
|
||||||
preferedLanguage: undefined,
|
mediaListCollectionIds: [],
|
||||||
searchEngine: "Jellyfin",
|
preferedLanguage: undefined,
|
||||||
marlinServerUrl: "",
|
searchEngine: "Jellyfin",
|
||||||
openInVLC: false,
|
marlinServerUrl: "",
|
||||||
downloadQuality: DownloadOptions[0],
|
openInVLC: false,
|
||||||
defaultBitrate: BITRATES[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
display: "list",
|
display: "list",
|
||||||
cardStyle: "detailed",
|
cardStyle: "detailed",
|
||||||
imageStyle: "cover",
|
imageStyle: "cover",
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
showStats: true,
|
showStats: true,
|
||||||
},
|
},
|
||||||
defaultAudioLanguage: null,
|
defaultAudioLanguage: null,
|
||||||
playDefaultAudioTrack: true,
|
playDefaultAudioTrack: true,
|
||||||
rememberAudioSelections: true,
|
rememberAudioSelections: true,
|
||||||
defaultSubtitleLanguage: null,
|
defaultSubtitleLanguage: null,
|
||||||
subtitleMode: SubtitlePlaybackMode.Default,
|
subtitleMode: SubtitlePlaybackMode.Default,
|
||||||
rememberSubtitleSelections: true,
|
rememberSubtitleSelections: true,
|
||||||
showHomeTitles: true,
|
showHomeTitles: true,
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
forwardSkipTime: 30,
|
forwardSkipTime: 30,
|
||||||
rewindSkipTime: 10,
|
rewindSkipTime: 10,
|
||||||
optimizedVersionsServerUrl: null,
|
optimizedVersionsServerUrl: null,
|
||||||
downloadMethod: DownloadMethod.Remux,
|
downloadMethod: DownloadMethod.Remux,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
showCustomMenuLinks: false,
|
showCustomMenuLinks: false,
|
||||||
disableHapticFeedback: false,
|
disableHapticFeedback: false,
|
||||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||||
remuxConcurrentLimit: 1,
|
remuxConcurrentLimit: 1,
|
||||||
safeAreaInControlsEnabled: true,
|
safeAreaInControlsEnabled: true,
|
||||||
jellyseerrServerUrl: undefined,
|
jellyseerrServerUrl: undefined,
|
||||||
hiddenLibraries: [],
|
hiddenLibraries: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Partial<Settings> => {
|
|
||||||
try {
|
try {
|
||||||
const jsonValue = storage.getString("settings");
|
const jsonValue = storage.getString("settings");
|
||||||
const loadedValues: Partial<Settings> =
|
const loadedValues: Partial<Settings> =
|
||||||
jsonValue != null ? JSON.parse(jsonValue) : {};
|
jsonValue != null ? JSON.parse(jsonValue) : {};
|
||||||
|
|
||||||
return loadedValues;
|
return { ...defaultValues, ...loadedValues };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load settings:", error);
|
console.error("Failed to load settings:", error);
|
||||||
return defaultValues;
|
return defaultValues;
|
||||||
@@ -225,7 +222,7 @@ const saveSettings = (settings: Settings) => {
|
|||||||
storage.set("settings", jsonValue);
|
storage.set("settings", jsonValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsAtom = atom<Partial<Settings> | null>(null);
|
export const settingsAtom = atom<Settings | null>(null);
|
||||||
export const pluginSettingsAtom = atom(
|
export const pluginSettingsAtom = atom(
|
||||||
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS)
|
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS)
|
||||||
);
|
);
|
||||||
@@ -265,18 +262,16 @@ export const useSettings = () => {
|
|||||||
|
|
||||||
const updateSettings = (update: Partial<Settings>) => {
|
const updateSettings = (update: Partial<Settings>) => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
const newSettings = { ..._settings, ...update };
|
const newSettings = { ...settings, ...update };
|
||||||
|
|
||||||
setSettings(newSettings);
|
setSettings(newSettings);
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
saveSettings(newSettings);
|
saveSettings(newSettings);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
||||||
// If admin sets locked to false but provides a value,
|
// If admin sets locked to false but provides a value,
|
||||||
// use user settings first and fallback on admin setting if required.
|
// use user settings first and fallback on admin setting if required.
|
||||||
const settings: Settings = useMemo(() => {
|
const settings: Settings = useMemo(() => {
|
||||||
let unlockedPluginDefaults = {} as Settings;
|
let unlockedPluginDefaults = {} as Settings;
|
||||||
const overrideSettings = Object.entries(pluginSettings || {}).reduce(
|
const overrideSettings = Object.entries(pluginSettings || {}).reduce(
|
||||||
@@ -305,8 +300,12 @@ export const useSettings = () => {
|
|||||||
{} as Settings
|
{} as Settings
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update settings with plugin defined defaults
|
||||||
|
if (Object.keys(unlockedPluginDefaults).length > 0) {
|
||||||
|
updateSettings(unlockedPluginDefaults);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultValues,
|
|
||||||
..._settings,
|
..._settings,
|
||||||
...overrideSettings,
|
...overrideSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,8 +92,10 @@ export function getDefaultPlaySettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Get default bitrate from settings or fallback to max
|
// 4. Get default bitrate
|
||||||
const bitrate = settings.defaultBitrate ?? BITRATES[0];
|
const bitrate = BITRATES.sort(
|
||||||
|
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||||
|
)[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export const getStreamUrl = async ({
|
|||||||
mediaSource: MediaSourceInfo | undefined;
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
} | null> => {
|
} | null> => {
|
||||||
if (!api || !userId || !item?.Id) {
|
if (!api || !userId || !item?.Id) {
|
||||||
console.warn("Missing required parameters for getStreamUrl");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +111,15 @@ export const getStreamUrl = async ({
|
|||||||
if (mediaSource?.TranscodingUrl) {
|
if (mediaSource?.TranscodingUrl) {
|
||||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||||
|
|
||||||
|
// If there is no subtitle stream index, add it to the URL.
|
||||||
|
if (subtitleStreamIndex == -1) {
|
||||||
|
urlObj.searchParams.set("SubtitleMethod", "Hls");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 'SubtitleMethod=Hls' if it doesn't exist
|
||||||
|
if (!urlObj.searchParams.has("SubtitleMethod")) {
|
||||||
|
urlObj.searchParams.append("SubtitleMethod", "Hls");
|
||||||
|
}
|
||||||
// Get the updated URL
|
// Get the updated URL
|
||||||
const transcodeUrl = urlObj.toString();
|
const transcodeUrl = urlObj.toString();
|
||||||
|
|
||||||
@@ -121,7 +129,9 @@ export const getStreamUrl = async ({
|
|||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
};
|
};
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (mediaSource?.SupportsDirectPlay) {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
playSessionId: sessionData?.PlaySessionId || "",
|
playSessionId: sessionData?.PlaySessionId || "",
|
||||||
mediaSourceId: mediaSource?.Id || "",
|
mediaSourceId: mediaSource?.Id || "",
|
||||||
@@ -148,4 +158,39 @@ export const getStreamUrl = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.MediaType === "Audio") {
|
||||||
|
if (mediaSource?.TranscodingUrl) {
|
||||||
|
return {
|
||||||
|
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
|
||||||
|
sessionId,
|
||||||
|
mediaSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
UserId: userId,
|
||||||
|
DeviceId: api.deviceInfo.id,
|
||||||
|
MaxStreamingBitrate: "140000000",
|
||||||
|
Container:
|
||||||
|
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||||
|
TranscodingContainer: "mp4",
|
||||||
|
TranscodingProtocol: "hls",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
api_key: api.accessToken,
|
||||||
|
PlaySessionId: sessionData?.PlaySessionId || "",
|
||||||
|
StartTimeTicks: "0",
|
||||||
|
EnableRedirection: "true",
|
||||||
|
EnableRemoteMedia: "false",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
url: `${
|
||||||
|
api.basePath
|
||||||
|
}/Audio/${itemId}/universal?${searchParams.toString()}`,
|
||||||
|
sessionId,
|
||||||
|
mediaSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unsupported media type");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const chromecastProfile: DeviceProfile = {
|
|||||||
Codec: "aac,mp3,flac,opus,vorbis",
|
Codec: "aac,mp3,flac,opus,vorbis",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ContainerProfiles: [],
|
|
||||||
DirectPlayProfiles: [
|
DirectPlayProfiles: [
|
||||||
{
|
{
|
||||||
Container: "mp4",
|
Container: "mp4",
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ export default {
|
|||||||
Type: MediaTypes.Video,
|
Type: MediaTypes.Video,
|
||||||
Context: "Streaming",
|
Context: "Streaming",
|
||||||
Protocol: "hls",
|
Protocol: "hls",
|
||||||
Container: "fmp4",
|
Container: "ts",
|
||||||
VideoCodec: "h264, hevc",
|
VideoCodec: "h264, hevc",
|
||||||
AudioCodec: "aac,mp3,ac3,dts",
|
AudioCodec: "aac,mp3,ac3",
|
||||||
|
CopyTimestamps: false,
|
||||||
|
EnableSubtitlesInManifest: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: MediaTypes.Audio,
|
Type: MediaTypes.Audio,
|
||||||
@@ -56,81 +58,131 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: [
|
SubtitleProfiles: [
|
||||||
// Official formats
|
// Official foramts
|
||||||
{ Format: "vtt", Method: "Embed" },
|
{ Format: "vtt", Method: "Embed" },
|
||||||
|
{ Format: "vtt", Method: "Hls" },
|
||||||
{ Format: "vtt", Method: "External" },
|
{ Format: "vtt", Method: "External" },
|
||||||
|
{ Format: "vtt", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "webvtt", Method: "Embed" },
|
{ Format: "webvtt", Method: "Embed" },
|
||||||
|
{ Format: "webvtt", Method: "Hls" },
|
||||||
{ Format: "webvtt", Method: "External" },
|
{ Format: "webvtt", Method: "External" },
|
||||||
|
{ Format: "webvtt", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "srt", Method: "Embed" },
|
{ Format: "srt", Method: "Embed" },
|
||||||
|
{ Format: "srt", Method: "Hls" },
|
||||||
{ Format: "srt", Method: "External" },
|
{ Format: "srt", Method: "External" },
|
||||||
|
{ Format: "srt", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "subrip", Method: "Embed" },
|
{ Format: "subrip", Method: "Embed" },
|
||||||
|
{ Format: "subrip", Method: "Hls" },
|
||||||
{ Format: "subrip", Method: "External" },
|
{ Format: "subrip", Method: "External" },
|
||||||
|
{ Format: "subrip", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ttml", Method: "Embed" },
|
{ Format: "ttml", Method: "Embed" },
|
||||||
|
{ Format: "ttml", Method: "Hls" },
|
||||||
{ Format: "ttml", Method: "External" },
|
{ Format: "ttml", Method: "External" },
|
||||||
|
{ Format: "ttml", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "dvbsub", Method: "Embed" },
|
{ Format: "dvbsub", Method: "Embed" },
|
||||||
|
{ Format: "dvbsub", Method: "Hls" },
|
||||||
|
{ Format: "dvbsub", Method: "External" },
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
{ Format: "dvdsub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ass", Method: "Embed" },
|
{ Format: "ass", Method: "Embed" },
|
||||||
|
{ Format: "ass", Method: "Hls" },
|
||||||
{ Format: "ass", Method: "External" },
|
{ Format: "ass", Method: "External" },
|
||||||
|
{ Format: "ass", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "idx", Method: "Embed" },
|
{ Format: "idx", Method: "Embed" },
|
||||||
|
{ Format: "idx", Method: "Hls" },
|
||||||
|
{ Format: "idx", Method: "External" },
|
||||||
{ Format: "idx", Method: "Encode" },
|
{ Format: "idx", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pgs", Method: "Embed" },
|
{ Format: "pgs", Method: "Embed" },
|
||||||
|
{ Format: "pgs", Method: "Hls" },
|
||||||
|
{ Format: "pgs", Method: "External" },
|
||||||
{ Format: "pgs", Method: "Encode" },
|
{ Format: "pgs", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pgssub", Method: "Embed" },
|
{ Format: "pgssub", Method: "Embed" },
|
||||||
|
{ Format: "pgssub", Method: "Hls" },
|
||||||
|
{ Format: "pgssub", Method: "External" },
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
{ Format: "pgssub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ssa", Method: "Embed" },
|
{ Format: "ssa", Method: "Embed" },
|
||||||
|
{ Format: "ssa", Method: "Hls" },
|
||||||
{ Format: "ssa", Method: "External" },
|
{ Format: "ssa", Method: "External" },
|
||||||
|
{ Format: "ssa", Method: "Encode" },
|
||||||
|
|
||||||
// Other formats
|
// Other formats
|
||||||
{ Format: "microdvd", Method: "Embed" },
|
{ Format: "microdvd", Method: "Embed" },
|
||||||
|
{ Format: "microdvd", Method: "Hls" },
|
||||||
{ Format: "microdvd", Method: "External" },
|
{ Format: "microdvd", Method: "External" },
|
||||||
|
{ Format: "microdvd", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "mov_text", Method: "Embed" },
|
{ Format: "mov_text", Method: "Embed" },
|
||||||
|
{ Format: "mov_text", Method: "Hls" },
|
||||||
{ Format: "mov_text", Method: "External" },
|
{ Format: "mov_text", Method: "External" },
|
||||||
|
{ Format: "mov_text", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "mpl2", Method: "Embed" },
|
{ Format: "mpl2", Method: "Embed" },
|
||||||
|
{ Format: "mpl2", Method: "Hls" },
|
||||||
{ Format: "mpl2", Method: "External" },
|
{ Format: "mpl2", Method: "External" },
|
||||||
|
{ Format: "mpl2", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pjs", Method: "Embed" },
|
{ Format: "pjs", Method: "Embed" },
|
||||||
|
{ Format: "pjs", Method: "Hls" },
|
||||||
{ Format: "pjs", Method: "External" },
|
{ Format: "pjs", Method: "External" },
|
||||||
|
{ Format: "pjs", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "realtext", Method: "Embed" },
|
{ Format: "realtext", Method: "Embed" },
|
||||||
|
{ Format: "realtext", Method: "Hls" },
|
||||||
{ Format: "realtext", Method: "External" },
|
{ Format: "realtext", Method: "External" },
|
||||||
|
{ Format: "realtext", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "scc", Method: "Embed" },
|
{ Format: "scc", Method: "Embed" },
|
||||||
|
{ Format: "scc", Method: "Hls" },
|
||||||
{ Format: "scc", Method: "External" },
|
{ Format: "scc", Method: "External" },
|
||||||
|
{ Format: "scc", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "smi", Method: "Embed" },
|
{ Format: "smi", Method: "Embed" },
|
||||||
|
{ Format: "smi", Method: "Hls" },
|
||||||
{ Format: "smi", Method: "External" },
|
{ Format: "smi", Method: "External" },
|
||||||
|
{ Format: "smi", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "stl", Method: "Embed" },
|
{ Format: "stl", Method: "Embed" },
|
||||||
|
{ Format: "stl", Method: "Hls" },
|
||||||
{ Format: "stl", Method: "External" },
|
{ Format: "stl", Method: "External" },
|
||||||
|
{ Format: "stl", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "sub", Method: "Embed" },
|
{ Format: "sub", Method: "Embed" },
|
||||||
|
{ Format: "sub", Method: "Hls" },
|
||||||
{ Format: "sub", Method: "External" },
|
{ Format: "sub", Method: "External" },
|
||||||
|
{ Format: "sub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "subviewer", Method: "Embed" },
|
{ Format: "subviewer", Method: "Embed" },
|
||||||
|
{ Format: "subviewer", Method: "Hls" },
|
||||||
{ Format: "subviewer", Method: "External" },
|
{ Format: "subviewer", Method: "External" },
|
||||||
|
{ Format: "subviewer", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "teletext", Method: "Embed" },
|
{ Format: "teletext", Method: "Embed" },
|
||||||
|
{ Format: "teletext", Method: "Hls" },
|
||||||
|
{ Format: "teletext", Method: "External" },
|
||||||
{ Format: "teletext", Method: "Encode" },
|
{ Format: "teletext", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "text", Method: "Embed" },
|
{ Format: "text", Method: "Embed" },
|
||||||
|
{ Format: "text", Method: "Hls" },
|
||||||
{ Format: "text", Method: "External" },
|
{ Format: "text", Method: "External" },
|
||||||
|
{ Format: "text", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "vplayer", Method: "Embed" },
|
{ Format: "vplayer", Method: "Embed" },
|
||||||
|
{ Format: "vplayer", Method: "Hls" },
|
||||||
{ Format: "vplayer", Method: "External" },
|
{ Format: "vplayer", Method: "External" },
|
||||||
|
{ Format: "vplayer", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "xsub", Method: "Embed" },
|
{ Format: "xsub", Method: "Embed" },
|
||||||
|
{ Format: "xsub", Method: "Hls" },
|
||||||
{ Format: "xsub", Method: "External" },
|
{ Format: "xsub", Method: "External" },
|
||||||
|
{ Format: "xsub", Method: "Encode" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
86
utils/profiles/transcoding.js
Normal file
86
utils/profiles/transcoding.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Name: "Vlc Player for HLS streams.",
|
||||||
|
MaxStaticBitrate: 20_000_000,
|
||||||
|
MaxStreamingBitrate: 12_000_000,
|
||||||
|
CodecProfiles: [
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Audio,
|
||||||
|
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
DirectPlayProfiles: [
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||||
|
VideoCodec:
|
||||||
|
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||||
|
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Audio,
|
||||||
|
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||||
|
AudioCodec:
|
||||||
|
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
TranscodingProfiles: [
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Video,
|
||||||
|
Context: "Streaming",
|
||||||
|
Protocol: "hls",
|
||||||
|
Container: "fmp4",
|
||||||
|
VideoCodec: "h264, hevc",
|
||||||
|
AudioCodec: "aac,mp3,ac3",
|
||||||
|
CopyTimestamps: false,
|
||||||
|
EnableSubtitlesInManifest: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: MediaTypes.Audio,
|
||||||
|
Context: "Streaming",
|
||||||
|
Protocol: "http",
|
||||||
|
Container: "mp3",
|
||||||
|
AudioCodec: "mp3",
|
||||||
|
MaxAudioChannels: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
SubtitleProfiles: [
|
||||||
|
// Text based subtitles must use HLS.
|
||||||
|
{ Format: "ass", Method: "Hls" },
|
||||||
|
{ Format: "microdvd", Method: "Hls" },
|
||||||
|
{ Format: "mov_text", Method: "Hls" },
|
||||||
|
{ Format: "mpl2", Method: "Hls" },
|
||||||
|
{ Format: "pjs", Method: "Hls" },
|
||||||
|
{ Format: "realtext", Method: "Hls" },
|
||||||
|
{ Format: "scc", Method: "Hls" },
|
||||||
|
{ Format: "smi", Method: "Hls" },
|
||||||
|
{ Format: "srt", Method: "Hls" },
|
||||||
|
{ Format: "ssa", Method: "Hls" },
|
||||||
|
{ Format: "stl", Method: "Hls" },
|
||||||
|
{ Format: "sub", Method: "Hls" },
|
||||||
|
{ Format: "subrip", Method: "Hls" },
|
||||||
|
{ Format: "subviewer", Method: "Hls" },
|
||||||
|
{ Format: "teletext", Method: "Hls" },
|
||||||
|
{ Format: "text", Method: "Hls" },
|
||||||
|
{ Format: "ttml", Method: "Hls" },
|
||||||
|
{ Format: "vplayer", Method: "Hls" },
|
||||||
|
{ Format: "vtt", Method: "Hls" },
|
||||||
|
{ Format: "webvtt", Method: "Hls" },
|
||||||
|
|
||||||
|
// Image based subs use encode.
|
||||||
|
{ Format: "dvdsub", Method: "Encode" },
|
||||||
|
{ Format: "pgs", Method: "Encode" },
|
||||||
|
{ Format: "pgssub", Method: "Encode" },
|
||||||
|
{ Format: "xsub", Method: "Encode" },
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user