forked from Ninjalama/streamyfin_mirror
Compare commits
89 Commits
v0.25.0
...
feat/switc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f07260177 | ||
|
|
09e9462ac0 | ||
|
|
dd65505f7f | ||
|
|
951158bcd3 | ||
|
|
9b1dd0923a | ||
|
|
bd908516b5 | ||
|
|
8cb10d1062 | ||
|
|
446439c2e0 | ||
|
|
a5463d783d | ||
|
|
640db35456 | ||
|
|
caa4b765c1 | ||
|
|
9c6aebe66a | ||
|
|
ef42510383 | ||
|
|
5273dfd22b | ||
|
|
00bc4232fb | ||
|
|
35c9258062 | ||
|
|
89bf51c3cc | ||
|
|
f64c5a02db | ||
|
|
cf284eb3d8 | ||
|
|
b581a077e1 | ||
|
|
e651b975b7 | ||
|
|
1c550b1b77 | ||
|
|
5bcae81538 | ||
|
|
c951725222 | ||
|
|
0b966d7c04 | ||
|
|
8e0e35afe3 | ||
|
|
daf7f35196 | ||
|
|
d5ac30b6d8 | ||
|
|
81b91bbb97 | ||
|
|
af2bd030e9 | ||
|
|
5590c2f784 | ||
|
|
6cc70dd123 | ||
|
|
fae588b0f0 | ||
|
|
bd2aeb2234 | ||
|
|
cca0bbf42c | ||
|
|
06e0eb5c4e | ||
|
|
b478fbb6bf | ||
|
|
b98a7b0634 | ||
|
|
ce38024a3f | ||
|
|
04dce9265b | ||
|
|
5b8418cd82 | ||
|
|
b0c5255bd7 | ||
|
|
73dd171987 | ||
|
|
ff35559687 | ||
|
|
5aadd50946 | ||
|
|
63b5ba2112 | ||
|
|
8b955578a2 | ||
|
|
1e5c021c93 | ||
|
|
0b86f56486 | ||
|
|
728b93f4e5 | ||
|
|
2fc483b24e | ||
|
|
fc901bc01e | ||
|
|
2b0884b154 | ||
|
|
307d20e538 | ||
|
|
a2f03908f6 | ||
|
|
77aef8877e | ||
|
|
0cf930d6e1 | ||
|
|
4b0b949541 | ||
|
|
14b717f985 | ||
|
|
cfbac538f8 | ||
|
|
1ac6b7e3df | ||
|
|
c9f6e8676b | ||
|
|
5aab1450cd | ||
|
|
1e7080a136 | ||
|
|
993cec4138 | ||
|
|
6c524499f9 | ||
|
|
b3463ffdfc | ||
|
|
50942b44f1 | ||
|
|
f602f8919f | ||
|
|
0e86d8a00f | ||
|
|
56b1e1977c | ||
|
|
30e23b9079 | ||
|
|
d83ecb881b | ||
|
|
4c14c08b35 | ||
|
|
ecb9b90163 | ||
|
|
33a2be24f4 | ||
|
|
e8b0d52515 | ||
|
|
9faa0de2d6 | ||
|
|
221155d002 | ||
|
|
4a37e17324 | ||
|
|
52b2a3418e | ||
|
|
2753b243e5 | ||
|
|
f22b356b7c | ||
|
|
d8ba5af8d9 | ||
|
|
505ef39ee7 | ||
|
|
e71d5cc176 | ||
|
|
74e57bbd88 | ||
|
|
76eaeb9820 | ||
|
|
9a70f98dd5 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,9 @@ body:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.27.0
|
||||
- 0.26.1
|
||||
- 0.26.0
|
||||
- 0.25.0
|
||||
- 0.24.0
|
||||
- 0.23.0
|
||||
|
||||
39
.github/workflows/main.yml
vendored
Normal file
39
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
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"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,6 +10,8 @@ npm-debug.*
|
||||
*.orig.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
modules/vlc-player/android/.gradle
|
||||
bun.lockb
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -41,4 +43,5 @@ credentials.json
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
.ruby-lsp
|
||||
modules/hls-downloader/android/build
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -9,6 +9,7 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
}
|
||||
|
||||
6
Makefile
Normal file
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
||||
e2e:
|
||||
maestro start-device --platform android
|
||||
maestro test login.yaml
|
||||
|
||||
e2e-setup:
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
88
README.md
88
README.md
@@ -38,7 +38,7 @@ Chromecast support is still in development, and we're working on improving it. C
|
||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
||||
|
||||
- Auto log in to Jellyseerr without the user having to do anythin
|
||||
- Choose the default languages
|
||||
- Choose the default languages
|
||||
- Set download method and search provider
|
||||
- Customize homescreen
|
||||
- And more...
|
||||
@@ -68,7 +68,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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -85,9 +85,9 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
||||
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed.
|
||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
4. run `npm run prebuild`
|
||||
5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
|
||||
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.
|
||||
|
||||
For the TV version suffix the npm commands with `:tv`.
|
||||
|
||||
@@ -123,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
|
||||
|
||||
## ✨ Acknowledgements
|
||||
|
||||
I'd like to thank the following people and projects for their contributions to Streamyfin:
|
||||
### Core Developers
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
<table>
|
||||
<tr
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
"
|
||||
>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Alexk2309">
|
||||
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Alexk2309</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/herrrta">
|
||||
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@herrrta</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lostb1t">
|
||||
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@lostb1t</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Simon-Eklundh">
|
||||
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/topiga">
|
||||
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@topiga</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/simoncaron">
|
||||
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@simoncaron</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/jakequade">
|
||||
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@jakequade</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Ryan0204">
|
||||
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Ryan0204</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/retardgerman">
|
||||
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@retardgerman</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/whoopsi-daisy">
|
||||
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||
|
||||
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
|
||||
39
app.json
39
app.json
@@ -2,24 +2,19 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.25.0",
|
||||
"version": "0.27.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||
"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.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
@@ -36,9 +31,11 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 50,
|
||||
"versionCode": 53,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png",
|
||||
"backgroundColor": "#464646"
|
||||
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
@@ -111,21 +108,11 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-bottom-tabs"
|
||||
],
|
||||
[
|
||||
"./plugins/withChangeNativeAndroidTextToWhite.js"
|
||||
],
|
||||
[
|
||||
"./plugins/withGoogleCastActivity.js"
|
||||
],
|
||||
[
|
||||
"./plugins/withTrustLocalCerts.js"
|
||||
],
|
||||
[
|
||||
"./plugins/withGradleProperties.js"
|
||||
],
|
||||
["react-native-bottom-tabs"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -155,4 +142,4 @@
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Ionicons, Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -27,13 +32,10 @@ export default function IndexLayout() {
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
{user && user.Policy?.IsAdministrator && (
|
||||
<SessionsButton />
|
||||
)}
|
||||
<SettingsButton />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -52,6 +54,12 @@ export default function IndexLayout() {
|
||||
title: t("home.downloads.tvseries"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="sessions/index"
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
@@ -112,3 +120,38 @@ export default function IndexLayout() {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const SettingsButton = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const SessionsButton = () => {
|
||||
const router = useRouter();
|
||||
const { sessions = [], _ } = useSessions({} as useSessionsProps);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/sessions");
|
||||
}}
|
||||
>
|
||||
<View className="mr-4">
|
||||
<Ionicons
|
||||
name="play-circle"
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={25}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,498 +1,5 @@
|
||||
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";
|
||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||
|
||||
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 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;
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
}
|
||||
|
||||
365
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
365
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { HardwareAccelerationType, SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import {
|
||||
Ionicons,
|
||||
Entypo,
|
||||
AntDesign,
|
||||
MaterialCommunityIcons,
|
||||
} from "@expo/vector-icons";
|
||||
import { Badge } from "@/components/Badge";
|
||||
|
||||
export default function page() {
|
||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!sessions || sessions.length == 0)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">
|
||||
{t("home.sessions.no_active_sessions")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: 17,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
data={sessions}
|
||||
renderItem={({ item }) => <SessionCard session={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionCardProps {
|
||||
session: SessionInfoDto;
|
||||
}
|
||||
|
||||
const SessionCard = ({ session }: SessionCardProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [remainingTicks, setRemainingTicks] = useState<number>(0);
|
||||
|
||||
const tick = () => {
|
||||
if (session.PlayState?.IsPaused) return;
|
||||
setRemainingTicks(remainingTicks - 10000000);
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||
(session.NowPlayingItem?.RunTimeTicks - remainingTicks)
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentTime = session.PlayState?.PositionTicks;
|
||||
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||
if (
|
||||
duration !== null &&
|
||||
duration !== undefined &&
|
||||
currentTime !== null &&
|
||||
currentTime !== undefined
|
||||
) {
|
||||
const remainingTimeTicks = duration - currentTime;
|
||||
setRemainingTicks(remainingTimeTicks);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useInterval(tick, 1000);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
|
||||
<View className="flex flex-row p-4">
|
||||
<View className="w-20 pr-4">
|
||||
<Poster
|
||||
id={session.NowPlayingItem?.Id}
|
||||
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||
/>
|
||||
</View>
|
||||
<View className="w-full flex-1">
|
||||
<View className="flex flex-row justify-between">
|
||||
<View className="flex-1 pr-4">
|
||||
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||
<>
|
||||
<Text className="font-bold">
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{session.NowPlayingItem.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-bold">
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{session.NowPlayingItem?.ProductionYear}
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{session.NowPlayingItem?.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-xs opacity-50 align-right text-right">
|
||||
{session.UserName}
|
||||
{"\n"}
|
||||
{session.Client}
|
||||
{"\n"}
|
||||
{session.DeviceName}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1" />
|
||||
<View className="flex flex-col align-bottom">
|
||||
<View className="flex flex-row justify-between align-bottom mb-1">
|
||||
<Text className="-ml-0.5 text-xs opacity-50 align-left text-left">
|
||||
{!session.PlayState?.IsPaused ? (
|
||||
<Ionicons name="play" size={14} color="white" />
|
||||
) : (
|
||||
<Ionicons name="pause" size={14} color="white" />
|
||||
)}
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50 align-right text-right">
|
||||
{formatTimeString(remainingTicks, "tick")} left
|
||||
</Text>
|
||||
</View>
|
||||
<View className="align-bottom bg-gray-800 h-1">
|
||||
<View
|
||||
className={`bg-purple-600 h-full`}
|
||||
style={{
|
||||
width: `${getProgressPercentage()}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<TranscodingView session={session} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TranscodingBadgesProps {
|
||||
properties: StreamProps;
|
||||
}
|
||||
|
||||
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
const iconMap = {
|
||||
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
|
||||
codec: <Ionicons name="layers-outline" size={12} color="white" />,
|
||||
videoRange: (
|
||||
<Ionicons name="color-palette-outline" size={12} color="white" />
|
||||
),
|
||||
resolution: <Ionicons name="film-outline" size={12} color="white" />,
|
||||
language: <Ionicons name="language-outline" size={12} color="white" />,
|
||||
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
|
||||
hwType: <Ionicons name="hardware-chip-outline" size={12} color="white" />,
|
||||
} as const;
|
||||
|
||||
const icon = (val: string) => {
|
||||
return (
|
||||
iconMap[val as keyof typeof iconMap] ?? (
|
||||
<Ionicons name="layers-outline" size={12} color="white" />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatVal = (key: string, val: any) => {
|
||||
switch (key) {
|
||||
case "bitrate":
|
||||
return formatBitrate(val);
|
||||
case "hwType":
|
||||
return val === HardwareAccelerationType.None ? "sw" : "hw";
|
||||
default:
|
||||
return val;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.entries(properties)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key]) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant="gray"
|
||||
className="m-0 p-0 pt-0.5 mr-1"
|
||||
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||
iconLeft={icon(key)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
interface StreamProps {
|
||||
hwType?: HardwareAccelerationType | null | undefined;
|
||||
resolution?: string | null | undefined;
|
||||
language?: string | null | undefined;
|
||||
codec?: string | null | undefined;
|
||||
bitrate?: number | null | undefined;
|
||||
videoRange?: string | null | undefined;
|
||||
audioChannels?: string | null | undefined;
|
||||
}
|
||||
|
||||
interface TranscodingStreamViewProps {
|
||||
title: string | undefined;
|
||||
value?: string;
|
||||
isTranscoding: Boolean;
|
||||
transcodeValue?: string | undefined | null;
|
||||
properties: StreamProps;
|
||||
transcodeProperties?: StreamProps;
|
||||
}
|
||||
|
||||
const TranscodingStreamView = ({
|
||||
title,
|
||||
isTranscoding,
|
||||
properties,
|
||||
transcodeProperties,
|
||||
value,
|
||||
transcodeValue,
|
||||
}: TranscodingStreamViewProps) => {
|
||||
return (
|
||||
<View className="flex flex-col pt-2 first:pt-0">
|
||||
<View className="flex flex-row">
|
||||
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="flex-1">
|
||||
<TranscodingBadges properties={properties} />
|
||||
</Text>
|
||||
</View>
|
||||
{isTranscoding && transcodeProperties ? (
|
||||
<>
|
||||
<View className="flex flex-row">
|
||||
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||
<MaterialCommunityIcons
|
||||
name="arrow-right-bottom"
|
||||
size={14}
|
||||
color="white"
|
||||
/>
|
||||
</Text>
|
||||
<Text className="flex-1 text-sm mt-1">
|
||||
<TranscodingBadges properties={transcodeProperties} />
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
const videoStream = useMemo(() => {
|
||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||
(s) => s.Type == "Video"
|
||||
)[0];
|
||||
}, [session]);
|
||||
|
||||
const audioStream = useMemo(() => {
|
||||
const index = session.PlayState?.AudioStreamIndex;
|
||||
return index !== null && index !== undefined
|
||||
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||
: undefined;
|
||||
}, [session.PlayState?.AudioStreamIndex]);
|
||||
|
||||
const subtitleStream = useMemo(() => {
|
||||
const index = session.PlayState?.SubtitleStreamIndex;
|
||||
return index !== null && index !== undefined
|
||||
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||
: undefined;
|
||||
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||
|
||||
const isTranscoding = useMemo(() => {
|
||||
return session.PlayState?.PlayMethod == "Transcode" && session.TranscodingInfo;
|
||||
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
|
||||
|
||||
const videoStreamTitle = () => {
|
||||
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
|
||||
<TranscodingStreamView
|
||||
title="Video"
|
||||
properties={{
|
||||
resolution: videoStreamTitle(),
|
||||
bitrate: videoStream?.BitRate,
|
||||
codec: videoStream?.Codec,
|
||||
}}
|
||||
transcodeProperties={{
|
||||
hwType: session.TranscodingInfo?.HardwareAccelerationType,
|
||||
bitrate: session.TranscodingInfo?.Bitrate,
|
||||
codec: session.TranscodingInfo?.VideoCodec,
|
||||
}}
|
||||
isTranscoding={
|
||||
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
|
||||
<TranscodingStreamView
|
||||
title="Audio"
|
||||
properties={{
|
||||
language: audioStream?.Language,
|
||||
bitrate: audioStream?.BitRate,
|
||||
codec: audioStream?.Codec,
|
||||
audioChannels: audioStream?.ChannelLayout,
|
||||
}}
|
||||
transcodeProperties={{
|
||||
codec: session.TranscodingInfo?.AudioCodec,
|
||||
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||
}}
|
||||
isTranscoding={
|
||||
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
|
||||
{subtitleStream && (
|
||||
<>
|
||||
<TranscodingStreamView
|
||||
title="Subtitle"
|
||||
isTranscoding={false}
|
||||
properties={{
|
||||
language: subtitleStream?.Language,
|
||||
codec: subtitleStream?.Codec,
|
||||
}}
|
||||
transcodeValue={null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Platform } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||
@@ -10,24 +11,24 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs } from "@/utils/log";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import React, { lazy, useEffect } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import React, { useEffect } from "react";
|
||||
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
const DownloadSettings = lazy(
|
||||
() => import("@/components/settings/DownloadSettings")
|
||||
);
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
|
||||
export default function settings() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { logout } = useJellyfin();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
@@ -62,6 +63,7 @@ export default function settings() {
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
<UserInfo />
|
||||
|
||||
<QuickConnect className="mb-4" />
|
||||
|
||||
<MediaProvider>
|
||||
@@ -72,12 +74,14 @@ export default function settings() {
|
||||
|
||||
<OtherSettings />
|
||||
|
||||
{!Platform.isTV && <DownloadSettings />}
|
||||
<DownloadSettings />
|
||||
|
||||
<PluginSettings />
|
||||
|
||||
<AppLanguageSelector />
|
||||
|
||||
<ChromecastSettings />
|
||||
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function page() {
|
||||
});
|
||||
|
||||
return await getStatistics({
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
url: updatedUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
});
|
||||
|
||||
@@ -42,25 +42,28 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
||||
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||
params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
} & Partial<MovieResult | TvResult>;
|
||||
mediaType: MediaType;
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -71,7 +74,7 @@ const Page: React.FC = () => {
|
||||
refetch,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
@@ -79,7 +82,7 @@ const Page: React.FC = () => {
|
||||
retryOnMount: true,
|
||||
refetchInterval: 0,
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
return mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
},
|
||||
@@ -111,10 +114,15 @@ const Page: React.FC = () => {
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const setRequestBody = useCallback((body: MediaRequestBody) => {
|
||||
_setRequestBody(body)
|
||||
advancedReqModalRef?.current?.present?.();
|
||||
}, [requestBody, _setRequestBody, advancedReqModalRef])
|
||||
|
||||
const request = useCallback(async () => {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
mediaType: mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
@@ -122,7 +130,7 @@ const Page: React.FC = () => {
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
advancedReqModalRef?.current?.present?.(body);
|
||||
setRequestBody(body)
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,7 +140,7 @@ const Page: React.FC = () => {
|
||||
const isAnime = useMemo(
|
||||
() =>
|
||||
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||
result.mediaType === MediaType.TV,
|
||||
mediaType === MediaType.TV,
|
||||
[details]
|
||||
);
|
||||
|
||||
@@ -200,7 +208,7 @@ const Page: React.FC = () => {
|
||||
<View className="px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-56">
|
||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||
<JellyserrRatings result={result as MovieResult | TvResult | MovieDetails | TvDetails} />
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
@@ -247,15 +255,14 @@ const Page: React.FC = () => {
|
||||
<OverviewText text={result.overview} className="mt-4" />
|
||||
</View>
|
||||
|
||||
{result.mediaType === MediaType.TV && (
|
||||
{mediaType === MediaType.TV && (
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
result={result as TvResult}
|
||||
details={details as TvDetails}
|
||||
refetch={refetch}
|
||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||
onAdvancedRequest={(data) =>
|
||||
advancedReqModalRef?.current?.present(data)
|
||||
setRequestBody(data)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -269,14 +276,17 @@ const Page: React.FC = () => {
|
||||
</ParallaxScrollView>
|
||||
<RequestModal
|
||||
ref={advancedReqModalRef}
|
||||
requestBody={requestBody}
|
||||
title={mediaTitle}
|
||||
id={result.id!!}
|
||||
type={result.mediaType as MediaType}
|
||||
type={mediaType}
|
||||
isAnime={isAnime}
|
||||
onRequested={() => {
|
||||
_setRequestBody(undefined)
|
||||
advancedReqModalRef?.current?.close();
|
||||
refetch();
|
||||
}}
|
||||
onDismiss={() => _setRequestBody(undefined)}
|
||||
/>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
||||
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
|
||||
@@ -32,15 +32,6 @@ export default function page() {
|
||||
enabled: !!jellyseerrApi && !!personId,
|
||||
});
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const region = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
);
|
||||
|
||||
const castedRoles: PersonCreditCast[] = useMemo(
|
||||
() =>
|
||||
uniqBy(orderBy(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const page: React.FC = () => {
|
||||
@@ -84,22 +84,26 @@ const page: React.FC = () => {
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<AddToFavorites item={item} type="series" />
|
||||
<DownloadItems
|
||||
size="large"
|
||||
title={t("item_card.download.download_series")}
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<DownloadItems
|
||||
size="large"
|
||||
title={t("item_card.download.download_series")}
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -38,9 +38,18 @@ export default function SearchLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
|
||||
<Stack.Screen
|
||||
name="jellyseerr/person/[personId]"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/company/[companyId]"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/genre/[genreId]"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,12 +26,14 @@ import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -50,7 +52,7 @@ export default function search() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
const { q } = params as { q: string };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
@@ -120,22 +122,44 @@ export default function search() {
|
||||
[api, searchEngine, settings]
|
||||
);
|
||||
|
||||
type HeaderSearchBarRef = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
setText: (text: string) => void;
|
||||
clearText: () => void;
|
||||
cancelSearch: () => void;
|
||||
};
|
||||
|
||||
const searchBarRef = useRef<HeaderSearchBarRef>(null);
|
||||
const navigation = useNavigation();
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: t("search.search"),
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
ref: searchBarRef,
|
||||
placeholder: t("search.search"),
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
});
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: false,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||
// Screen not actuve
|
||||
if (!searchBarRef.current) return;
|
||||
// Screen is active, focus search bar
|
||||
searchBarRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: movies, isFetching: l1 } = useQuery({
|
||||
queryKey: ["search", "movies", debouncedSearch],
|
||||
queryFn: () =>
|
||||
@@ -210,19 +234,12 @@ export default function search() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
keyboardType="web-search"
|
||||
placeholder={t("search.search_here")}
|
||||
value={search}
|
||||
onChangeText={(text) => setSearch(text)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -21,6 +20,7 @@ import type {
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
BottomTabNavigationOptions,
|
||||
@@ -55,12 +55,19 @@ export default function TabLayout() {
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
ignoresTopSafeArea
|
||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||
tabBarStyle={{
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
scrollEdgeAppearance="default"
|
||||
>
|
||||
<NativeTabs.Screen redirect name="index" />
|
||||
<NativeTabs.Screen
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
eventBus.emit("scrollToTop");
|
||||
},
|
||||
})}
|
||||
name="(home)"
|
||||
options={{
|
||||
title: t("tabs.home"),
|
||||
@@ -75,6 +82,11 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
eventBus.emit("searchTabPressed");
|
||||
},
|
||||
})}
|
||||
name="(search)"
|
||||
options={{
|
||||
title: t("tabs.search"),
|
||||
|
||||
@@ -3,16 +3,21 @@ import React, { useEffect } from "react";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const [settings] = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
if (settings.defaultVideoOrientation) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
if (settings.autoRotate === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
@@ -36,15 +41,6 @@ export default function Layout() {
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="transcoding-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,63 +3,47 @@ import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
import {
|
||||
PipStartedPayload,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
// import { useDownload } from "@/providers/DownloadProvider";
|
||||
const downloadProvider = !Platform.isTV
|
||||
? require("@/providers/DownloadProvider")
|
||||
: null;
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import native from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
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 { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
BackHandler,
|
||||
View,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import React, { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||
import { Alert, View, Platform } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import settings from "../(tabs)/(home)/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
PlaybackOrder,
|
||||
PlaybackProgressInfo,
|
||||
PlaybackStartInfo,
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
export default function page() {
|
||||
console.log("Direct Player");
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
@@ -67,6 +51,7 @@ export default function page() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
@@ -101,145 +86,115 @@ export default function page() {
|
||||
offline: string;
|
||||
}>();
|
||||
const [settings] = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: BITRATES[0].value;
|
||||
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (offline && !Platform.isTV) {
|
||||
const item = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (item) return item.item;
|
||||
}
|
||||
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
staleTime: 0,
|
||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||
const [itemStatus, setItemStatus] = useState({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return null;
|
||||
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
|
||||
if (item)
|
||||
return {
|
||||
mediaSource: data.mediaSource,
|
||||
url,
|
||||
sessionId: undefined,
|
||||
};
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
setItemStatus({ isLoading: true, isError: false });
|
||||
try {
|
||||
let fetchedItem: BaseItemDto | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (data) fetchedItem = data.item as BaseItemDto;
|
||||
} else {
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
fetchedItem = res.data;
|
||||
}
|
||||
setItem(fetchedItem);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch item:", error);
|
||||
setItemStatus({ isLoading: false, isError: true });
|
||||
} finally {
|
||||
setItemStatus({ isLoading: false, isError: false });
|
||||
}
|
||||
};
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: native,
|
||||
});
|
||||
if (itemId) {
|
||||
fetchItemData();
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
|
||||
if (!res) return null;
|
||||
interface Stream {
|
||||
mediaSource: MediaSourceInfo;
|
||||
sessionId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
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 [stream, setStream] = useState<Stream | null>(null);
|
||||
const [streamStatus, setStreamStatus] = useState({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!api) return;
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async () => {
|
||||
try {
|
||||
let result: Stream | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return;
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
if (item) {
|
||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||
}
|
||||
} else {
|
||||
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) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||
return;
|
||||
}
|
||||
result = { mediaSource, sessionId, url };
|
||||
}
|
||||
setStream(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stream:", error);
|
||||
setStreamStatus({ isLoading: false, isError: true });
|
||||
} finally {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
}
|
||||
};
|
||||
fetchStreamData();
|
||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
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 {
|
||||
videoRef.current?.play();
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
stream,
|
||||
videoRef,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
offline,
|
||||
progress.value,
|
||||
]);
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.value);
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
mediaSourceId: mediaSourceId,
|
||||
@@ -256,54 +211,67 @@ export default function page() {
|
||||
videoRef.current?.stop();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
// TODO: unused should remove.
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (offline) return;
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
return () => {
|
||||
beforeRemoveListener();
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
|
||||
if (!stream) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
const currentPlayStateInfo = () => {
|
||||
return {
|
||||
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]);
|
||||
positionTicks: msToTicks(progress.get()),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
isMuted: false,
|
||||
canSeek: true,
|
||||
repeatMode: RepeatMode.RepeatNone,
|
||||
playbackOrder: PlaybackOrder.Default,
|
||||
};
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
|
||||
const { currentTime } = data.nativeEvent;
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
|
||||
progress.value = currentTime;
|
||||
progress.set(currentTime);
|
||||
|
||||
if (offline) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(currentTime);
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(currentTimeInTicks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
reportPlaybackProgress();
|
||||
},
|
||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
|
||||
);
|
||||
|
||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||
const { pipStarted } = e.nativeEvent;
|
||||
setIsPipStarted(pipStarted);
|
||||
}, []);
|
||||
|
||||
const reportPlaybackProgress = useCallback(async () => {
|
||||
if (!api || offline || !stream) return;
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
});
|
||||
}, [api, isPlaying, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]);
|
||||
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
|
||||
}, [item]);
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
@@ -311,127 +279,81 @@ export default function page() {
|
||||
offline,
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
} else if (isBuffering) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
|
||||
return item?.UserData?.PlaybackPositionTicks
|
||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||
: 0;
|
||||
}, [item]);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
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();
|
||||
}
|
||||
const onPlaybackStateChanged = useCallback(
|
||||
async (e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
reportPlaybackProgress();
|
||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||
return;
|
||||
}
|
||||
setAppState(nextAppState);
|
||||
};
|
||||
|
||||
// Use AppState.addEventListener and return a cleanup function
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange
|
||||
);
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
reportPlaybackProgress();
|
||||
if (!Platform.isTV) await deactivateKeepAwake();
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup the event listener when the component is unmounted
|
||||
subscription.remove();
|
||||
};
|
||||
}, [appState]);
|
||||
if (isPlaying) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
} else if (isBuffering) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[reportPlaybackProgress]
|
||||
);
|
||||
|
||||
// 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") || [];
|
||||
|
||||
// Move all the external subtitles last, because vlc places them last.
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub: { Type: string }) => sub.Type === "Subtitle"
|
||||
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
|
||||
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
|
||||
) || [];
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub: { Index: number }) => sub.Index === subtitleIndex
|
||||
);
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio: { Type: string }) => audio.Type === "Audio"
|
||||
) || [];
|
||||
const chosenAudioTrack = allAudio.find(
|
||||
(audio: { Index: number | undefined }) => audio.Index === audioIndex
|
||||
);
|
||||
|
||||
// Direct playback CASE
|
||||
if (!bitrateValue) {
|
||||
// If Subtitle is embedded we can use the position to select it straight away.
|
||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
||||
// If Subtitle is external we need to pass the URL to the player.
|
||||
externalTrack = {
|
||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||
};
|
||||
}
|
||||
const externalSubtitles = allSubs
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
|
||||
if (chosenAudioTrack)
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
} else {
|
||||
// Transcoded playback CASE
|
||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||
externalTrack = {
|
||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
||||
DeliveryUrl: "",
|
||||
};
|
||||
}
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
|
||||
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
|
||||
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
if (notTranscoding && chosenAudioTrack) {
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
}
|
||||
|
||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// Add useEffect to handle mounting
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">{t("player.error")}</Text>
|
||||
@@ -455,32 +377,29 @@ export default function page() {
|
||||
<VlcPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream.url,
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
externalTrack,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onVideoProgress={onProgress}
|
||||
progressUpdateInterval={1000}
|
||||
onVideoStateChange={onPlaybackStateChanged}
|
||||
onVideoLoadStart={() => {}}
|
||||
onPipStarted={onPipStarted}
|
||||
onVideoLoadEnd={() => {
|
||||
setIsVideoLoaded(true);
|
||||
}}
|
||||
onVideoError={(e) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video")
|
||||
);
|
||||
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{videoRef.current && (
|
||||
{videoRef.current && !isPipStarted && isMounted === true ? (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
@@ -496,6 +415,7 @@ export default function page() {
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||
play={videoRef.current?.play}
|
||||
pause={videoRef.current?.pause}
|
||||
seek={videoRef.current?.seekTo}
|
||||
@@ -506,29 +426,9 @@ export default function page() {
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
stop={stop}
|
||||
isVlc
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import transcoding from "@/utils/profiles/transcoding";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Player = () => {
|
||||
console.log("Transcoding 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,
|
||||
]
|
||||
);
|
||||
|
||||
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,6 +1,5 @@
|
||||
import "@/augmentations";
|
||||
import { Platform } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
@@ -10,10 +9,6 @@ import {
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import {
|
||||
SplashScreenProvider,
|
||||
useSplashScreenLoading,
|
||||
} from "@/providers/SplashScreenProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
@@ -32,16 +27,15 @@ const BackgroundFetch = !Platform.isTV
|
||||
? require("expo-background-fetch")
|
||||
: null;
|
||||
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;
|
||||
import { router, Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||
import { getLocales } from "expo-localization";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { I18nextProvider, useTranslation } from "react-i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -58,6 +52,15 @@ 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() {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -224,17 +227,15 @@ export default function RootLayout() {
|
||||
Appearance.setColorScheme("dark");
|
||||
|
||||
return (
|
||||
<SplashScreenProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<JotaiProvider>
|
||||
<ActionSheetProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Layout />
|
||||
</I18nextProvider>
|
||||
</ActionSheetProvider>
|
||||
</JotaiProvider>
|
||||
</GestureHandlerRootView>
|
||||
</SplashScreenProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<JotaiProvider>
|
||||
<ActionSheetProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Layout />
|
||||
</I18nextProvider>
|
||||
</ActionSheetProvider>
|
||||
</JotaiProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,17 +262,15 @@ function Layout() {
|
||||
}, [settings?.preferedLanguage, i18n]);
|
||||
|
||||
if (!Platform.isTV) {
|
||||
useKeepAwake();
|
||||
useNotificationObserver();
|
||||
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If the user has auto rotate enabled, unlock the orientation
|
||||
if (Platform.isTV) return;
|
||||
if (settings.autoRotate === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
@@ -303,16 +302,6 @@ function Layout() {
|
||||
}, []);
|
||||
}
|
||||
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
});
|
||||
|
||||
useSplashScreenLoading(!loaded);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JobQueueProvider>
|
||||
@@ -324,7 +313,7 @@ function Layout() {
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack>
|
||||
<Stack initialRouteName="(auth)/(tabs)">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -19,17 +19,20 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Keyboard } from "react-native";
|
||||
|
||||
import { z } from "zod";
|
||||
import { t } from 'i18next';
|
||||
import { t } from "i18next";
|
||||
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 } =
|
||||
useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -37,6 +40,8 @@ const CredentialsSchema = z.object({
|
||||
password: _password,
|
||||
} = 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 [serverName, setServerName] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
@@ -47,10 +52,11 @@ const CredentialsSchema = z.object({
|
||||
password: _password,
|
||||
});
|
||||
|
||||
/**
|
||||
* A way to auto login based on a link
|
||||
*/
|
||||
useEffect(() => {
|
||||
(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) {
|
||||
setServer({
|
||||
address: _apiUrl,
|
||||
@@ -66,7 +72,6 @@ const CredentialsSchema = z.object({
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: serverName,
|
||||
@@ -79,15 +84,17 @@ const CredentialsSchema = z.object({
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
|
||||
<Text className="ml-2 text-purple-600">
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
});
|
||||
}, [serverName, navigation, api?.basePath]);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
@@ -98,15 +105,16 @@ const CredentialsSchema = z.object({
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
} else {
|
||||
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured")
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Checks the availability and validity of a Jellyfin server URL.
|
||||
*
|
||||
@@ -180,14 +188,21 @@ const CredentialsSchema = z.object({
|
||||
try {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
]);
|
||||
Alert.alert(
|
||||
t("login.quick_connect"),
|
||||
t("login.enter_code_to_login", { code: code }),
|
||||
[
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
|
||||
Alert.alert(
|
||||
t("login.error_title"),
|
||||
t("login.failed_to_initiate_quick_connect")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,16 +216,18 @@ const CredentialsSchema = z.object({
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{t("login.login_to_title") + " "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : t("login.login_title")}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{t("login.login_to_title") + " "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
@@ -220,7 +237,6 @@ const CredentialsSchema = z.object({
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
@@ -300,7 +316,9 @@ const CredentialsSchema = z.object({
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => await handleConnect(serverURL)}
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
className="w-full grow"
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 79 KiB |
@@ -1,113 +1,23 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { View } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
type: "item" | "series";
|
||||
}
|
||||
|
||||
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const isFavorite = useMemo(() => {
|
||||
return item.UserData?.IsFavorite;
|
||||
}, [item.UserData?.IsFavorite]);
|
||||
|
||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
||||
[type, item.Id],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const markFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).markFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
},
|
||||
});
|
||||
|
||||
const unmarkFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
},
|
||||
});
|
||||
|
||||
export const AddToFavorites = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite, _} = useFavorite(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size="large"
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
fillColor={isFavorite ? "primary" : undefined}
|
||||
onPress={() => {
|
||||
if (isFavorite) {
|
||||
unmarkFavoriteMutation.mutate();
|
||||
} else {
|
||||
markFavoriteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { Href, router, useFocusEffect } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, View, ViewProps } from "react-native";
|
||||
import { Alert, Platform, View, ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
@@ -66,10 +66,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||
settings?.defaultBitrate ?? {
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
@@ -162,7 +164,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
|
||||
);
|
||||
}
|
||||
}, [
|
||||
queue,
|
||||
@@ -194,10 +198,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
||||
item,
|
||||
settings!
|
||||
));
|
||||
const defaults = getDefaultPlaySettings(item, settings!);
|
||||
mediaSource = defaults.mediaSource;
|
||||
audioIndex = defaults.audioIndex;
|
||||
subtitleIndex = defaults.subtitleIndex;
|
||||
// Keep using the selected bitrate for consistency across all downloads
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
@@ -332,7 +337,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-neutral-300">
|
||||
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
|
||||
{subtitle ||
|
||||
t("item_card.download.download_x_item", {
|
||||
item_count: itemsNotDownloaded.length,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 w-full items-start">
|
||||
@@ -390,12 +398,16 @@ export const DownloadSingleItem: React.FC<{
|
||||
size?: "default" | "large";
|
||||
item: BaseItemDto;
|
||||
}> = ({ item, size = "default" }) => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
return (
|
||||
<DownloadItems
|
||||
size={size}
|
||||
title={item.Type == "Episode"
|
||||
? t("item_card.download.download_episode")
|
||||
: t("item_card.download.download_movie")}
|
||||
title={
|
||||
item.Type == "Episode"
|
||||
? t("item_card.download.download_episode")
|
||||
: t("item_card.download.download_movie")
|
||||
}
|
||||
subtitle={item.Name!}
|
||||
items={[item]}
|
||||
MissingDownloadIconComponent={() => (
|
||||
|
||||
@@ -21,14 +21,19 @@ export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], t
|
||||
);
|
||||
};
|
||||
|
||||
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
||||
export const Tags: React.FC<TagProps & {tagProps?: ViewProps} & ViewProps> = ({
|
||||
tags,
|
||||
textClass = "text-xs",
|
||||
tagProps,
|
||||
...props
|
||||
}) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||
{tags.map((tag, idx) => (
|
||||
<View key={idx}>
|
||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||
<Tag key={idx} textClass={textClass} text={tag} {...tagProps}/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -15,8 +15,8 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import {
|
||||
@@ -25,17 +25,16 @@ import {
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -95,9 +94,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
/>
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
)}
|
||||
<PlayedStatus items={[item]} size="large" />
|
||||
<AddToFavorites item={item} type="item" />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -118,37 +119,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
@@ -196,7 +166,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col bg-transparent shrink">
|
||||
{/* {!Platform.isTV && ( */}
|
||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||
<ItemHeader item={item} className="mb-4" />
|
||||
{item.Type !== "Program" && !Platform.isTV && (
|
||||
@@ -239,7 +208,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
isTranscoding={isTranscoding}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
@@ -255,13 +223,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* {!Platform.isTV && ( */}
|
||||
<PlayButton
|
||||
className="grow"
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
/>
|
||||
{/* )} */}
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Button } from "./Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
|
||||
interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -54,14 +55,18 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
<BottomSheetScrollView>
|
||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
||||
<Text className="text-lg font-bold mb-4">
|
||||
{t("item_card.video")}
|
||||
</Text>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
{t("item_card.audio")}
|
||||
</Text>
|
||||
<AudioStreamInfo
|
||||
audioStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
@@ -72,7 +77,9 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
<SubtitleStreamInfo
|
||||
subtitleStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
@@ -229,12 +236,3 @@ const formatFileSize = (bytes?: number | null) => {
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatBitrate = (bitrate?: number | null) => {
|
||||
if (!bitrate) return "N/A";
|
||||
|
||||
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
||||
if (bitrate === 0) return "0 bps";
|
||||
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
||||
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform } from "react-native";
|
||||
import { Platform, Pressable } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -32,9 +32,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
const chromecastProfile = !Platform.isTV
|
||||
? require("@/utils/profiles/chromecast")
|
||||
: null;
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
@@ -72,17 +71,14 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
if (!bitrateValue) {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
return;
|
||||
}
|
||||
router.push(`/player/transcoding-player?${q}`);
|
||||
(q: string) => {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -98,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
if (!client) {
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
goToPlayer(queryString);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,16 +113,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
if (!Platform.isTV) {
|
||||
await CastContext.getPlayServicesState().then(async (state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
// Get a new URL with the Chromecast device profile:
|
||||
await CastContext.getPlayServicesState().then(async (state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS) {
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
} else {
|
||||
// Check if user wants H265 for Chromecast
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
|
||||
// Get a new URL with the Chromecast device profile
|
||||
try {
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: chromecastProfile,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
@@ -135,6 +134,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||
});
|
||||
|
||||
console.log("URL: ", data?.url, enableH265);
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
@@ -209,12 +210,14 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
goToPlayer(queryString);
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
@@ -323,75 +326,62 @@ export const PlayButton: React.FC<Props> = ({
|
||||
*/
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
<Feather name="cast" size={22} />
|
||||
<CastButton tintColor="transparent" />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
<CastButton tintColor="transparent" />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{/* <View className="mt-2 flex flex-row items-center">
|
||||
<Ionicons
|
||||
name="information-circle"
|
||||
size={12}
|
||||
className=""
|
||||
color={"#9BA1A6"}
|
||||
/>
|
||||
<Text className="text-neutral-500 ml-1">
|
||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||
</Text>
|
||||
</View> */}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,10 +34,10 @@ const ANIMATION_DURATION = 500;
|
||||
const MIN_PLAYBACK_WIDTH = 15;
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
}: Props) => {
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -57,17 +57,14 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
if (!bitrateValue) {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
return;
|
||||
}
|
||||
router.push(`/player/transcoding-player?${q}`);
|
||||
(q: string) => {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
const onPress = () => {
|
||||
console.log("onpress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -81,17 +78,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
goToPlayer(queryString);
|
||||
return;
|
||||
}, [
|
||||
item,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
router,
|
||||
showActionSheetWithOptions,
|
||||
selectedOptions,
|
||||
]);
|
||||
};
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
@@ -99,9 +88,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
)
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
@@ -183,69 +172,55 @@ export const PlayButton: React.FC<Props> = ({
|
||||
*/
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
animatedWidthStyle,
|
||||
{
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{/* <View className="mt-2 flex flex-row items-center">
|
||||
<Ionicons
|
||||
name="information-circle"
|
||||
size={12}
|
||||
className=""
|
||||
color={"#9BA1A6"}
|
||||
/>
|
||||
<Text className="text-neutral-500 ml-1">
|
||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||
</Text>
|
||||
</View> */}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import {useMemo} from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -49,14 +52,17 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
|
||||
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDetails | MovieDetails }> = ({
|
||||
result,
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { jellyseerrApi, getMediaType } = useJellyseerr();
|
||||
|
||||
const mediaType = useMemo(() => getMediaType(result), [result]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
|
||||
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
return mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieRatings(result.id)
|
||||
: jellyseerrApi?.tvRatings(result.id);
|
||||
},
|
||||
|
||||
@@ -4,40 +4,31 @@ import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "./common/Text";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
isTranscoding?: boolean;
|
||||
}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
isTranscoding,
|
||||
...props
|
||||
}) => {
|
||||
if (Platform.isTV) return null;
|
||||
const subtitleStreams = useMemo(() => {
|
||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||
|
||||
if (isTranscoding && Platform.OS === "ios") {
|
||||
return subtitleHelper.getUniqueSubtitles();
|
||||
}
|
||||
|
||||
return subtitleHelper.getSubtitles();
|
||||
}, [source, isTranscoding]);
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
() => subtitleStreams?.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected]
|
||||
);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
if (subtitleStreams?.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -52,7 +43,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col " {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
<Text numberOfLines={1} className="opacity-50 mb-1 text-xs">
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props<T> {
|
||||
title: string | ReactNode;
|
||||
label: string;
|
||||
onSelected: (...item: T[]) => void;
|
||||
multi?: boolean;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown = <T extends unknown>({
|
||||
@@ -30,7 +30,7 @@ const Dropdown = <T extends unknown>({
|
||||
title,
|
||||
label,
|
||||
onSelected,
|
||||
multi = false,
|
||||
multiple = false,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
if (Platform.isTV) return null;
|
||||
@@ -72,7 +72,7 @@ const Dropdown = <T extends unknown>({
|
||||
>
|
||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||
{data.map((item, idx) =>
|
||||
multi ? (
|
||||
multiple ? (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={
|
||||
selected?.some((s) => keyExtractor(s) == keyExtractor(item))
|
||||
@@ -80,7 +80,7 @@ const Dropdown = <T extends unknown>({
|
||||
: "off"
|
||||
}
|
||||
key={keyExtractor(item)}
|
||||
onValueChange={(next, previous) =>
|
||||
onValueChange={(next: "on" | "off", previous: "on" | "off") => {
|
||||
setSelected((p) => {
|
||||
const prev = p || [];
|
||||
if (next == "on") {
|
||||
@@ -92,7 +92,7 @@ const Dropdown = <T extends unknown>({
|
||||
),
|
||||
];
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{titleExtractor(item)}
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
result: MovieResult | TvResult;
|
||||
result: MovieResult | TvResult | MovieDetails | TvDetails;
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: boolean;
|
||||
posterSrc: string;
|
||||
mediaType: MediaType;
|
||||
}
|
||||
|
||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -24,6 +27,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
mediaType,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
@@ -46,7 +50,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: result.id,
|
||||
mediaType: result.mediaType,
|
||||
mediaType,
|
||||
}),
|
||||
[jellyseerrApi, result]
|
||||
);
|
||||
@@ -67,6 +71,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
mediaType
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -83,7 +88,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
{canRequest && result.mediaType === MediaType.MOVIE && (
|
||||
{canRequest && mediaType === MediaType.MOVIE && (
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
onSelect={() => {
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import React from "react";
|
||||
import { TextProps } from "react-native";
|
||||
import { Platform, TextProps } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
|
||||
import { Text as RNText } from "react-native";
|
||||
export function Text(
|
||||
props: TextProps & {
|
||||
uiTextView?: boolean;
|
||||
}
|
||||
) {
|
||||
const { style, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<UITextView
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
if (Platform.isTV)
|
||||
return (
|
||||
<RNText
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<UITextView
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
@@ -7,7 +8,6 @@ import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -57,14 +57,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
||||
|
||||
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
if (!(item.Type === "Movie" || item.Type === "Episode" || item.Type === "Series")) return;
|
||||
const options = ["Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", "Cancel"];
|
||||
const cancelButtonIndex = 3;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -74,14 +74,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
await markAsPlayedStatus(true);
|
||||
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else if (selectedIndex === 1) {
|
||||
await markAsPlayedStatus(false);
|
||||
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite()
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [showActionSheetWithOptions, markAsPlayedStatus]);
|
||||
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||
|
||||
if (
|
||||
from === "(home)" ||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
||||
import { useAtom } from "jotai";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
@@ -21,10 +20,12 @@ import {
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
import { Image } from "expo-image";
|
||||
import { useMemo } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { t } from "i18next";
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
const FFmpegKitProvider = !Platform.isTV
|
||||
? require("ffmpeg-kit-react-native")
|
||||
: null;
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -33,14 +34,20 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
if (processes?.length === 0)
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text>
|
||||
<Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text>
|
||||
<Text className="text-lg font-bold">
|
||||
{t("home.downloads.active_download")}
|
||||
</Text>
|
||||
<Text className="opacity-50">
|
||||
{t("home.downloads.no_active_downloads")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
{processes?.map((p: JobStatus) => (
|
||||
<DownloadCard key={p.item.Id} process={p} />
|
||||
@@ -81,7 +88,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
}
|
||||
} else {
|
||||
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||
setProcesses((prev: any[]) => prev.filter((p: { id: string; }) => p.id !== id));
|
||||
setProcesses((prev: any[]) =>
|
||||
prev.filter((p: { id: string }) => p.id !== id)
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -156,7 +165,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text>
|
||||
<Text className="text-xs">
|
||||
{t("home.downloads.eta", { eta: eta(process) })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ interface Release {
|
||||
type: number;
|
||||
}
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
export const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -50,18 +50,9 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { jellyseerrUser } = useJellyseerr();
|
||||
const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const region = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
);
|
||||
|
||||
const releases = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.releases?.results.find(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
||||
import PersonPoster from "./PersonPoster";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {uniqBy} from "lodash";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
searchQuery: string;
|
||||
@@ -77,25 +78,28 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
|
||||
|
||||
const jellyseerrMovieResults = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE
|
||||
) as MovieResult[],
|
||||
uniqBy(
|
||||
jellyseerrResults?.filter((r) => r.mediaType === MediaType.MOVIE) as MovieResult[],
|
||||
"id"
|
||||
),
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrTvResults = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.TV
|
||||
) as TvResult[],
|
||||
uniqBy(
|
||||
jellyseerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
|
||||
"id"
|
||||
),
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrPersonResults = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === "person"
|
||||
) as PersonResult[],
|
||||
uniqBy(
|
||||
jellyseerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
|
||||
"id"
|
||||
),
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
|
||||
@@ -15,18 +15,22 @@ import { useTranslation } from "react-i18next";
|
||||
interface Props {
|
||||
id: number;
|
||||
title: string,
|
||||
requestBody?: MediaRequestBody,
|
||||
type: MediaType;
|
||||
isAnime?: boolean;
|
||||
is4k?: boolean;
|
||||
onRequested?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
|
||||
id,
|
||||
title,
|
||||
requestBody,
|
||||
type,
|
||||
isAnime = false,
|
||||
onRequested,
|
||||
onDismiss,
|
||||
...props
|
||||
}, ref) => {
|
||||
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
|
||||
@@ -39,8 +43,6 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
|
||||
|
||||
const {data: serviceSettings} = useQuery({
|
||||
queryKey: ["jellyseerr", "request", type, 'service'],
|
||||
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
|
||||
@@ -98,16 +100,19 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
|
||||
: defaultServiceDetails?.server.activeTags
|
||||
)?.includes(t.id)
|
||||
) ?? []
|
||||
|
||||
console.log(tags)
|
||||
return tags
|
||||
},
|
||||
[defaultServiceDetails]
|
||||
);
|
||||
|
||||
const seasonTitle = useMemo(
|
||||
() => modalRequestProps?.seasons?.length ? t("jellyseerr.season_x", {seasons: modalRequestProps?.seasons}) : undefined,
|
||||
[modalRequestProps?.seasons]
|
||||
() => {
|
||||
if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
|
||||
return t("jellyseerr.season_all")
|
||||
}
|
||||
return t("jellyseerr.season_number", {season_number: requestBody?.seasons})
|
||||
},
|
||||
[requestBody?.seasons]
|
||||
);
|
||||
|
||||
const request = useCallback(() => {requestMedia(
|
||||
@@ -117,12 +122,12 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
|
||||
profileId: defaultProfile.id,
|
||||
rootFolder: defaultFolder.path,
|
||||
tags: defaultTags.map(t => t.id),
|
||||
...modalRequestProps,
|
||||
...requestBody,
|
||||
...requestOverrides
|
||||
},
|
||||
onRequested
|
||||
)
|
||||
}, [modalRequestProps, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
|
||||
}, [requestBody, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
@@ -131,7 +136,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
|
||||
ref={ref}
|
||||
enableDynamicSizing
|
||||
enableDismissOnClose
|
||||
onDismiss={() => setModalRequestProps(undefined)}
|
||||
onDismiss={onDismiss}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
@@ -146,89 +151,86 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(data) => {
|
||||
setModalRequestProps(data?.data as MediaRequestBody)
|
||||
return <BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
|
||||
{seasonTitle &&
|
||||
<Text className="text-neutral-300">{seasonTitle}</Text>
|
||||
}
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{(defaultService && defaultServiceDetails && users) && (
|
||||
<>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.profiles}
|
||||
titleExtractor={(item) => item.name}
|
||||
placeholderText={defaultProfile.name}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.quality_profile")}
|
||||
onSelected={(item) =>
|
||||
item && setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
profileId: item?.id
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.rootFolders}
|
||||
titleExtractor={pathTitleExtractor}
|
||||
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.root_folder")}
|
||||
onSelected={(item) =>
|
||||
item && setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
rootFolder: item.path
|
||||
}))}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
/>
|
||||
<Dropdown
|
||||
multi={true}
|
||||
data={defaultServiceDetails.tags}
|
||||
titleExtractor={(item) => item.label}
|
||||
placeholderText={defaultTags.map(t => t.label).join(",")}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.tags")}
|
||||
onSelected={(...item) =>
|
||||
item && setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
tags: item.map(i => i.id)
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={users}
|
||||
titleExtractor={(item) => item.displayName}
|
||||
placeholderText={jellyseerrUser!!.displayName}
|
||||
keyExtractor={(item) => item.id.toString() || ""}
|
||||
label={t("jellyseerr.request_as")}
|
||||
onSelected={(item) =>
|
||||
item && setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
userId: item?.id
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={request}
|
||||
color="purple"
|
||||
>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
|
||||
{seasonTitle &&
|
||||
<Text className="text-neutral-300">{seasonTitle}</Text>
|
||||
}
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
}}
|
||||
<View className="flex flex-col space-y-2">
|
||||
{(defaultService && defaultServiceDetails && users) && (
|
||||
<>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.profiles}
|
||||
titleExtractor={(item) => item.name}
|
||||
placeholderText={requestOverrides.profileName || defaultProfile.name}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.quality_profile")}
|
||||
onSelected={(item) =>
|
||||
item && setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
profileId: item?.id
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.rootFolders}
|
||||
titleExtractor={pathTitleExtractor}
|
||||
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.root_folder")}
|
||||
onSelected={(item) =>
|
||||
item && setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
rootFolder: item.path
|
||||
}))}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
/>
|
||||
<Dropdown
|
||||
multiple
|
||||
data={defaultServiceDetails.tags}
|
||||
titleExtractor={(item) => item.label}
|
||||
placeholderText={defaultTags.map(t => t.label).join(",")}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.tags")}
|
||||
onSelected={(...selected) =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
tags: selected.map(i => i.id)
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={users}
|
||||
titleExtractor={(item) => item.displayName}
|
||||
placeholderText={jellyseerrUser!!.displayName}
|
||||
keyExtractor={(item) => item.id.toString() || ""}
|
||||
label={t("jellyseerr.request_as")}
|
||||
onSelected={(item) =>
|
||||
item && setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
userId: item?.id
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={request}
|
||||
color="purple"
|
||||
>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {View} from "react-native";
|
||||
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
|
||||
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
|
||||
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
|
||||
|
||||
interface Props {
|
||||
sliders?: DiscoverSlider[];
|
||||
@@ -25,6 +26,8 @@ const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
<View className="flex flex-col space-y-4 mb-8">
|
||||
{sortedSliders.map(slide => {
|
||||
switch (slide.type) {
|
||||
case DiscoverSliderType.RECENT_REQUESTS:
|
||||
return <RecentRequestsSlide key={slide.id} slide={slide} />
|
||||
case DiscoverSliderType.NETWORKS:
|
||||
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
|
||||
case DiscoverSliderType.STUDIOS:
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
|
||||
import {ViewProps} from "react-native";
|
||||
import {uniqBy} from "lodash";
|
||||
|
||||
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
@@ -57,7 +58,11 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() => data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||
() =>
|
||||
uniqBy(
|
||||
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||
"id"
|
||||
),
|
||||
[data]
|
||||
);
|
||||
|
||||
@@ -74,7 +79,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
|
||||
fetchNextPage()
|
||||
}}
|
||||
renderItem={(item) =>
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} key={item?.id}/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
69
components/jellyseerr/discover/RecentRequestsSlide.tsx
Normal file
69
components/jellyseerr/discover/RecentRequestsSlide.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
|
||||
import {ViewProps} from "react-native";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import {NonFunctionProperties} from "@/utils/jellyseerr/server/interfaces/api/common";
|
||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
|
||||
const RequestCard: React.FC<{request: MediaRequest}> = ({request}) => {
|
||||
const {jellyseerrApi} = useJellyseerr();
|
||||
|
||||
const { data: details, isLoading, isError } = useQuery({
|
||||
queryKey: ["jellyseerr", "detail", request.media.mediaType, request.media.tmdbId],
|
||||
queryFn: async () => {
|
||||
|
||||
return request.media.mediaType == MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(request.media.tmdbId)
|
||||
: jellyseerrApi?.tvDetails(request.media.tmdbId);
|
||||
},
|
||||
enabled: !!jellyseerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: refreshedRequest } = useQuery({
|
||||
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
|
||||
queryFn: async () => jellyseerrApi?.getRequest(request.id),
|
||||
enabled: !!jellyseerrApi,
|
||||
refetchOnMount: true,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
details && <JellyseerrPoster horizontal showDownloadInfo item={details} mediaRequest={refreshedRequest} />
|
||||
)
|
||||
}
|
||||
|
||||
const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const {jellyseerrApi} = useJellyseerr();
|
||||
|
||||
const { data: requests, isLoading, isError } = useQuery({
|
||||
queryKey: ["jellyseerr", "recent_requests"],
|
||||
queryFn: async () => jellyseerrApi?.requests(),
|
||||
enabled: !!jellyseerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
requests &&
|
||||
requests.results.length > 0 &&
|
||||
!isError && (
|
||||
<Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={requests.results}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
|
||||
<RequestCard request={item}/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
export default RecentRequestsSlide;
|
||||
@@ -29,7 +29,7 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
</Text>
|
||||
<View
|
||||
style={[]}
|
||||
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
|
||||
className="flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900"
|
||||
>
|
||||
{Children.map(childrenArray, (child, index) => {
|
||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
{...props}
|
||||
@@ -55,7 +55,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
{...props}
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { Image } from "expo-image";
|
||||
import { useMemo } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {useJellyseerrCanRequest} from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {Image} from "expo-image";
|
||||
import {useMemo} from "react";
|
||||
import {View, ViewProps} from "react-native";
|
||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";
|
||||
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {DownloadingItem} from "@/utils/jellyseerr/server/lib/downloadtracker";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import {Colors} from "@/constants/Colors";
|
||||
import {Tags} from "@/components/GenreTags";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: MovieResult | TvResult;
|
||||
item: MovieResult | TvResult | MovieDetails | TvDetails;
|
||||
horizontal?: boolean;
|
||||
showDownloadInfo?: boolean;
|
||||
mediaRequest?: MediaRequest;
|
||||
}
|
||||
|
||||
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const JellyseerrPoster: React.FC<Props> = ({
|
||||
item,
|
||||
horizontal,
|
||||
showDownloadInfo,
|
||||
mediaRequest,
|
||||
...props
|
||||
}) => {
|
||||
const { jellyseerrApi, getTitle, getYear, getMediaType, isJellyseerrResult } = useJellyseerr();
|
||||
const loadingOpacity = useSharedValue(1);
|
||||
const imageOpacity = useSharedValue(0);
|
||||
const {t} = useTranslation();
|
||||
|
||||
const loadingAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: loadingOpacity.value,
|
||||
@@ -38,27 +52,64 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
||||
};
|
||||
|
||||
const imageSrc = useMemo(
|
||||
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
|
||||
[item, jellyseerrApi]
|
||||
() => jellyseerrApi?.imageProxy(
|
||||
horizontal ? item.backdropPath : item.posterPath,
|
||||
horizontal ? "w1920_and_h800_multi_faces" : "w300_and_h450_face"
|
||||
),
|
||||
[item, jellyseerrApi, horizontal]
|
||||
);
|
||||
|
||||
const title = useMemo(
|
||||
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
|
||||
[item]
|
||||
);
|
||||
const title = useMemo(() => getTitle(item), [item]);
|
||||
const releaseYear = useMemo(() => getYear(item), [item]);
|
||||
const mediaType = useMemo(() => getMediaType(item), [item]);
|
||||
|
||||
const releaseYear = useMemo(
|
||||
() =>
|
||||
new Date(
|
||||
item.mediaType === MediaType.MOVIE
|
||||
? item.releaseDate
|
||||
: item.firstAirDate
|
||||
).getFullYear(),
|
||||
[item]
|
||||
);
|
||||
const size = useMemo(() => horizontal ? 'h-28' : 'w-28', [horizontal])
|
||||
const ratio = useMemo(() => horizontal ? '15/10' : '10/15', [horizontal])
|
||||
|
||||
const [canRequest] = useJellyseerrCanRequest(item);
|
||||
|
||||
const is4k = useMemo(
|
||||
() => mediaRequest?.is4k === true,
|
||||
[mediaRequest]
|
||||
);
|
||||
|
||||
const downloadItems = useMemo(
|
||||
() => (is4k ? mediaRequest?.media.downloadStatus4k : mediaRequest?.media.downloadStatus) || [],
|
||||
[mediaRequest, is4k]
|
||||
)
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const [totalSize, sizeLeft] = downloadItems
|
||||
.reduce((sum: number[], next: DownloadingItem) =>
|
||||
[sum[0] + next.size, sum[1] + next.sizeLeft],
|
||||
[0, 0]
|
||||
);
|
||||
|
||||
return (((totalSize - sizeLeft) / totalSize) * 100);
|
||||
},
|
||||
[downloadItems]
|
||||
);
|
||||
|
||||
const requestedSeasons: string[] | undefined = useMemo(
|
||||
() => {
|
||||
const seasons = mediaRequest?.seasons?.flatMap(s => s.seasonNumber.toString()) || []
|
||||
if (seasons.length > 4) {
|
||||
const [first, second, third, fourth, ...rest] = seasons;
|
||||
return [first, second, third, fourth, t("home.settings.plugins.jellyseerr.plus_n_more", {n: rest.length })]
|
||||
}
|
||||
return seasons
|
||||
},
|
||||
[mediaRequest]
|
||||
);
|
||||
|
||||
const available = useMemo(
|
||||
() => {
|
||||
const status = mediaRequest?.media?.[is4k ? 'status4k' : 'status'];
|
||||
return status === MediaStatus.AVAILABLE
|
||||
},
|
||||
[mediaRequest, is4k]
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableJellyseerrRouter
|
||||
result={item}
|
||||
@@ -66,9 +117,10 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
||||
releaseYear={releaseYear}
|
||||
canRequest={canRequest}
|
||||
posterSrc={imageSrc!!}
|
||||
mediaType={mediaType}
|
||||
>
|
||||
<View className="flex flex-col w-28 mr-2">
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||
<View className={`flex flex-col mr-2 h-auto`}>
|
||||
<View className={`relative rounded-lg overflow-hidden border border-neutral-900 ${size} aspect-[${ratio}]`}>
|
||||
<Animated.View style={imageAnimatedStyle}>
|
||||
<Image
|
||||
key={item.id}
|
||||
@@ -77,26 +129,65 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
aspectRatio: ratio,
|
||||
[horizontal ? 'height' : 'width']: "100%"
|
||||
}}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</Animated.View>
|
||||
{mediaRequest && showDownloadInfo && (
|
||||
<>
|
||||
<View className={`absolute w-full h-full bg-black ${!available ? 'opacity-70' : 'opacity-0'}`} />
|
||||
{!available && !Number.isNaN(progress) && (
|
||||
<>
|
||||
<View
|
||||
className="absolute left-0 h-full opacity-40"
|
||||
style={{
|
||||
width: `${progress || 0}%`,
|
||||
backgroundColor: Colors.primaryRGB,
|
||||
}}
|
||||
/>
|
||||
<View className="absolute w-full h-full justify-center items-center">
|
||||
<Text
|
||||
className="font-bold"
|
||||
style={textShadowStyle.shadow}
|
||||
>
|
||||
{progress?.toFixed(0)}%
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<Text
|
||||
className="absolute right-1 top-1 text-right font-bold"
|
||||
style={textShadowStyle.shadow}
|
||||
>
|
||||
{mediaRequest?.requestedBy.displayName}
|
||||
</Text>
|
||||
{requestedSeasons.length > 0 && (
|
||||
<Tags
|
||||
className="absolute bottom-1 left-0.5 w-32"
|
||||
tagProps={{
|
||||
className: "bg-black rounded-full px-1"
|
||||
}}
|
||||
tags={requestedSeasons}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<JellyseerrStatusIcon
|
||||
className="absolute bottom-1 right-1"
|
||||
showRequestIcon={canRequest}
|
||||
mediaStatus={item?.mediaInfo?.status}
|
||||
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
|
||||
/>
|
||||
<JellyseerrMediaIcon
|
||||
className="absolute top-1 left-1"
|
||||
mediaType={item?.mediaType}
|
||||
mediaType={mediaType}
|
||||
/>
|
||||
</View>
|
||||
<View className="mt-2 flex flex-col">
|
||||
<Text numberOfLines={2}>{title}</Text>
|
||||
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={`mt-2 flex flex-col ${horizontal ? 'w-44' : 'w-28'}`}>
|
||||
<Text numberOfLines={2}>{title}</Text>
|
||||
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
|
||||
</View>
|
||||
</TouchableJellyseerrRouter>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ import { Loader } from "../Loader";
|
||||
import { t } from "i18next";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
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<{
|
||||
details: TvDetails;
|
||||
@@ -52,26 +54,51 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
||||
};
|
||||
|
||||
const RenderItem = ({ item, index }: any) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||
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 (
|
||||
<View className="flex flex-col w-44 mt-2">
|
||||
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
{!imageError ? (
|
||||
<Image
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<Image
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
onError={(e) => {
|
||||
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">
|
||||
<Ionicons
|
||||
@@ -101,14 +128,12 @@ const RenderItem = ({ item, index }: any) => {
|
||||
|
||||
const JellyseerrSeasons: React.FC<{
|
||||
isLoading: boolean;
|
||||
result?: TvResult;
|
||||
details?: TvDetails;
|
||||
hasAdvancedRequest?: boolean,
|
||||
onAdvancedRequest?: (data: MediaRequestBody) => void;
|
||||
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
||||
}> = ({
|
||||
isLoading,
|
||||
result,
|
||||
details,
|
||||
refetch,
|
||||
hasAdvancedRequest,
|
||||
@@ -168,7 +193,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
return onAdvancedRequest?.(body)
|
||||
}
|
||||
|
||||
requestMedia(result?.name!!, body, refetch);
|
||||
requestMedia(details.name, body, refetch);
|
||||
}
|
||||
}, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
|
||||
|
||||
@@ -200,7 +225,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
return onAdvancedRequest?.(body)
|
||||
}
|
||||
|
||||
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
|
||||
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
|
||||
}
|
||||
}, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);
|
||||
|
||||
|
||||
22
components/settings/ChromecastSettings.tsx
Normal file
22
components/settings/ChromecastSettings.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Switch, View } from "react-native";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Chromecast"}>
|
||||
<ListItem title={"Enable H265 for Chromecast"}>
|
||||
<Switch
|
||||
value={settings.enableH265ForChromecast}
|
||||
onValueChange={(enableH265ForChromecast) =>
|
||||
updateSettings({ enableH265ForChromecast })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
30
components/settings/Dashboard.tsx
Normal file
30
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useRouter } from "expo-router";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings) return null;
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.dashboard.title")} className="mt-4">
|
||||
<ListItem
|
||||
className={sessions.length != 0 ? "bg-purple-900" : ""}
|
||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||
title={t("home.settings.dashboard.sessions_title")}
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -62,7 +62,7 @@ export default function DownloadSettings({ ...props }) {
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("home.settings.downloads.methods")}
|
||||
{t("home.settings.downloads.download_method")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
|
||||
5
components/settings/DownloadSettings.tv.tsx
Normal file
5
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
export default function DownloadSettings({ ...props }) {
|
||||
return <></>;
|
||||
}
|
||||
507
components/settings/HomeIndex.tsx
Normal file
507
components/settings/HomeIndex.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
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 { eventBus } from "@/utils/eventBus";
|
||||
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,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 HomeIndex = () => {
|
||||
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 scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
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 segments = useSegments();
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||
if (segments[2] === "(home)")
|
||||
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [segments]);
|
||||
|
||||
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
|
||||
scrollToOverflowEnabled={true}
|
||||
ref={scrollViewRef}
|
||||
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;
|
||||
}
|
||||
453
components/settings/HomeIndex.tv.tsx
Normal file
453
components/settings/HomeIndex.tv.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
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 HomeIndex = () => {
|
||||
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;
|
||||
}
|
||||
@@ -26,9 +26,6 @@ export const JellyseerrSettings = () => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
@@ -39,11 +36,16 @@ export const JellyseerrSettings = () => {
|
||||
|
||||
const loginToJellyseerrMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
|
||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||
throw new Error("Missing server url");
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
}
|
||||
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
|
||||
const jellyseerrTempApi = new JellyseerrApi(
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
|
||||
);
|
||||
const testResult = await jellyseerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
@@ -57,31 +59,11 @@ 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 = () => {
|
||||
clearAllJellyseerData().finally(() => {
|
||||
setJellyseerrUser(undefined);
|
||||
setJellyseerrPassword(undefined);
|
||||
setjellyseerrServerUrl(undefined);
|
||||
setPromptForJellyseerrPass(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -92,34 +74,46 @@ export const JellyseerrSettings = () => {
|
||||
<>
|
||||
<ListGroup title={"Jellyseerr"}>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
|
||||
title={t(
|
||||
"home.settings.plugins.jellyseerr.total_media_requests"
|
||||
)}
|
||||
value={jellyseerrUser?.requestCount?.toString()}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
|
||||
value={
|
||||
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
|
||||
jellyseerrUser?.movieQuotaLimit?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
|
||||
value={
|
||||
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
|
||||
jellyseerrUser?.movieQuotaDays?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
|
||||
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
|
||||
value={
|
||||
jellyseerrUser?.tvQuotaLimit?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
|
||||
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
|
||||
value={
|
||||
jellyseerrUser?.tvQuotaDays?.toString() ??
|
||||
t("home.settings.plugins.jellyseerr.unlimited")
|
||||
}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className="p-4">
|
||||
<Button color="red" onPress={clearData}>
|
||||
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
|
||||
{t(
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
@@ -128,15 +122,20 @@ export const JellyseerrSettings = () => {
|
||||
<Text className="text-xs text-red-600 mb-2">
|
||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||
</Text>
|
||||
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
|
||||
<Text className="font-bold mb-1">
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
<View className="flex flex-col shrink mb-2">
|
||||
<Text className="text-xs text-gray-600">
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
|
||||
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
||||
className="border border-neutral-800 mb-2"
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder"
|
||||
)}
|
||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
@@ -145,40 +144,20 @@ export const JellyseerrSettings = () => {
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!testJellyseerrServerUrlMutation.isPending}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={testJellyseerrServerUrlMutation.isPending}
|
||||
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>
|
||||
<View>
|
||||
<Text className="font-bold mb-2">
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
</Text>
|
||||
<Input
|
||||
className="border border-neutral-800"
|
||||
autoFocus={true}
|
||||
focusable={true}
|
||||
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||
{ username: user?.Name }
|
||||
)}
|
||||
value={jellyseerrPassword}
|
||||
keyboardType="default"
|
||||
secureTextEntry={true}
|
||||
@@ -186,10 +165,7 @@ export const JellyseerrSettings = () => {
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
onChangeText={setJellyseerrPassword}
|
||||
editable={
|
||||
!loginToJellyseerrMutation.isPending &&
|
||||
promptForJellyseerrPass
|
||||
}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
loading={loginToJellyseerrMutation.isPending}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Platform } from "react-native";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { ScreenOrientationEnum, useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
unregisterBackgroundFetchAsync,
|
||||
} from "@/utils/background-tasks";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
const BackgroundFetch = !Platform.isTV
|
||||
? require("expo-background-fetch")
|
||||
: null;
|
||||
const BackgroundFetch = !Platform.isTV ? require("expo-background-fetch") : null;
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -21,6 +20,7 @@ import { ListItem } from "../list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
import { isNumber } from "lodash";
|
||||
|
||||
export const OtherSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -83,10 +83,7 @@ export const OtherSettings: React.FC = () => {
|
||||
return (
|
||||
<DisabledSetting disabled={disabled}>
|
||||
<ListGroup title={t("home.settings.other.other_title")} className="">
|
||||
<ListItem
|
||||
title={t("home.settings.other.auto_rotate")}
|
||||
disabled={pluginSettings?.autoRotate?.locked}
|
||||
>
|
||||
<ListItem title={t("home.settings.other.auto_rotate")} disabled={pluginSettings?.autoRotate?.locked}>
|
||||
<Switch
|
||||
value={settings.autoRotate}
|
||||
disabled={pluginSettings?.autoRotate?.locked}
|
||||
@@ -96,17 +93,11 @@ export const OtherSettings: React.FC = () => {
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_orientation")}
|
||||
disabled={
|
||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||
settings.autoRotate
|
||||
}
|
||||
disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
|
||||
>
|
||||
<Dropdown
|
||||
data={orientations}
|
||||
disabled={
|
||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||
settings.autoRotate
|
||||
}
|
||||
disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => ScreenOrientationEnum[item]}
|
||||
title={
|
||||
@@ -114,17 +105,11 @@ export const OtherSettings: React.FC = () => {
|
||||
<Text className="mr-1 text-[#8E8D91]">
|
||||
{t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-expand-sharp"
|
||||
size={18}
|
||||
color="#5A5960"
|
||||
/>
|
||||
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.orientation")}
|
||||
onSelected={(defaultVideoOrientation) =>
|
||||
updateSettings({ defaultVideoOrientation })
|
||||
}
|
||||
onSelected={(defaultVideoOrientation) => updateSettings({ defaultVideoOrientation })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -135,27 +120,49 @@ export const OtherSettings: React.FC = () => {
|
||||
<Switch
|
||||
value={settings.safeAreaInControlsEnabled}
|
||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ safeAreaInControlsEnabled: value })
|
||||
}
|
||||
onValueChange={(value) => updateSettings({ safeAreaInControlsEnabled: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_player")}
|
||||
disabled={pluginSettings?.defaultPlayer?.locked}
|
||||
>
|
||||
<Dropdown
|
||||
data={Object.values(VideoPlayer).filter(isNumber)}
|
||||
disabled={pluginSettings?.defaultPlayer?.locked}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
|
||||
title={
|
||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||
<Text className="mr-1 text-[#8E8D91]">
|
||||
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-expand-sharp"
|
||||
size={18}
|
||||
color="#5A5960"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.orientation")}
|
||||
onSelected={(defaultPlayer) =>
|
||||
updateSettings({ defaultPlayer })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)} */}
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.show_custom_menu_links")}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
||||
)
|
||||
}
|
||||
onPress={() => Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.showCustomMenuLinks}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showCustomMenuLinks: value })
|
||||
}
|
||||
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
@@ -163,6 +170,23 @@ export const OtherSettings: React.FC = () => {
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
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
|
||||
title={t("home.settings.other.disable_haptic_feedback")}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
@@ -170,9 +194,7 @@ export const OtherSettings: React.FC = () => {
|
||||
<Switch
|
||||
value={settings.disableHapticFeedback}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
onValueChange={(disableHapticFeedback) =>
|
||||
updateSettings({ disableHapticFeedback })
|
||||
}
|
||||
onValueChange={(disableHapticFeedback) => updateSettings({ disableHapticFeedback })}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner-native";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {Colors} from "@/constants/Colors";
|
||||
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
@@ -61,7 +62,7 @@ export const StorageSettings = () => {
|
||||
<View
|
||||
style={{
|
||||
width: `${(size.app / size.total) * 100}%`,
|
||||
backgroundColor: "rgb(147 51 234)",
|
||||
backgroundColor: Colors.primaryRGB,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
@@ -70,7 +71,7 @@ export const StorageSettings = () => {
|
||||
((size.total - size.remaining - size.app) / size.total) *
|
||||
100
|
||||
}%`,
|
||||
backgroundColor: "rgb(192 132 252)",
|
||||
backgroundColor: Colors.primaryLightRGB,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,61 +1,40 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import {
|
||||
TrackInfo,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDefaultPlaySettings,
|
||||
previousIndexes,
|
||||
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {Loader} from "@/components/Loader";
|
||||
import {useAdjacentItems} from "@/hooks/useAdjacentEpisodes";
|
||||
import {useCreditSkipper} from "@/hooks/useCreditSkipper";
|
||||
import {useHaptic} from "@/hooks/useHaptic";
|
||||
import {useIntroSkipper} from "@/hooks/useIntroSkipper";
|
||||
import {useTrickplay} from "@/hooks/useTrickplay";
|
||||
import {TrackInfo, VlcPlayerViewRef,} from "@/modules/VlcPlayer.types";
|
||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||
import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
|
||||
import {getDefaultPlaySettings,} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import {getItemById} from "@/utils/jellyfin/user-library/getItemById";
|
||||
import {writeToLog} from "@/utils/log";
|
||||
import {formatTimeString, msToTicks, secondsToMs, ticksToMs, ticksToSeconds,} from "@/utils/time";
|
||||
import {Ionicons, MaterialIcons} from "@expo/vector-icons";
|
||||
import {BaseItemDto, MediaSourceInfo,} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {Image} from "expo-image";
|
||||
import {useLocalSearchParams, useRouter} from "expo-router";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { VideoRef } from "react-native-video";
|
||||
import {useAtom} from "jotai";
|
||||
import {debounce} from "lodash";
|
||||
import React, {useCallback, useEffect, useRef, useState} from "react";
|
||||
import {Platform, TouchableOpacity, useWindowDimensions, View,} from "react-native";
|
||||
import {Slider} from "react-native-awesome-slider";
|
||||
import {runOnJS, SharedValue, useAnimatedReaction, useSharedValue,} from "react-native-reanimated";
|
||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||
import {VideoRef} from "react-native-video";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
||||
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import {ControlProvider} from "./contexts/ControlContext";
|
||||
import {VideoProvider} from "./contexts/VideoContext";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import {EpisodeList} from "./EpisodeList";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||
import {useControlsTimeout} from "./useControlsTimeout";
|
||||
import {VideoTouchOverlay} from "./VideoTouchOverlay";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
@@ -75,6 +54,7 @@ interface Props {
|
||||
isVideoLoaded?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
play: (() => Promise<void>) | (() => void);
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
@@ -82,39 +62,38 @@ interface Props {
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
stop: (() => Promise<void>) | (() => void);
|
||||
isVlc?: boolean;
|
||||
}
|
||||
|
||||
const CONTROLS_TIMEOUT = 4000;
|
||||
|
||||
export const Controls: React.FC<Props> = ({
|
||||
item,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
progress,
|
||||
isBuffering,
|
||||
cacheProgress,
|
||||
showControls,
|
||||
setShowControls,
|
||||
ignoreSafeAreas,
|
||||
setIgnoreSafeAreas,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
stop,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
item,
|
||||
seek,
|
||||
startPictureInPicture,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
progress,
|
||||
isBuffering,
|
||||
cacheProgress,
|
||||
showControls,
|
||||
setShowControls,
|
||||
ignoreSafeAreas,
|
||||
setIgnoreSafeAreas,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -183,81 +162,60 @@ export const Controls: React.FC<Props> = ({
|
||||
isVlc
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !settings) return;
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!item || !settings) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
lightHapticFeedback();
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
};
|
||||
const previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
previousItem,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined
|
||||
);
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
item,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router]
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem) return;
|
||||
goToItemCommon(previousItem);
|
||||
}, [previousItem, goToItemCommon]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
if (!nextItem) return;
|
||||
goToItemCommon(nextItem);
|
||||
}, [nextItem, goToItemCommon]);
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
nextItem,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) return;
|
||||
goToItemCommon(gotoItem);
|
||||
},
|
||||
[goToItemCommon, api]
|
||||
);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
@@ -381,52 +339,6 @@ export const Controls: React.FC<Props> = ({
|
||||
}
|
||||
}, [settings, isPlaying, isVlc]);
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
try {
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!settings || !gotoItem) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
gotoItem,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||
bitrateValue: bitrateValue.toString(),
|
||||
}).toString();
|
||||
|
||||
if (!bitrateValue) {
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.replace(`player/transcoding-player?${queryParams}`);
|
||||
} catch (error) {
|
||||
console.error("Error in gotoEpisode:", error);
|
||||
}
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex]
|
||||
);
|
||||
|
||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||
setIgnoreSafeAreas((prev) => !prev);
|
||||
lightHapticFeedback();
|
||||
@@ -499,6 +411,14 @@ export const Controls: React.FC<Props> = ({
|
||||
);
|
||||
}, [trickPlayUrl, trickplayInfo, time]);
|
||||
|
||||
const onClose = async () => {
|
||||
lightHapticFeedback();
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
@@ -534,23 +454,35 @@ export const Controls: React.FC<Props> = ({
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row w-full pt-2`}
|
||||
>
|
||||
<View className="mr-auto">
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
{!mediaSource?.TranscodingUrl ? (
|
||||
<DropdownViewDirect showControls={showControls} />
|
||||
) : (
|
||||
<DropdownViewTranscoding showControls={showControls} />
|
||||
)}
|
||||
</VideoProvider>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<View className="mr-auto">
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<DropdownView />
|
||||
</VideoProvider>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="flex flex-row items-center space-x-2 ">
|
||||
{!Platform.isTV && settings.defaultPlayer == VideoPlayer.VLC_4 && (
|
||||
<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 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
@@ -592,13 +524,7 @@ export const Controls: React.FC<Props> = ({
|
||||
</TouchableOpacity>
|
||||
{/* )} */}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
lightHapticFeedback();
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
router.back();
|
||||
}}
|
||||
onPress={onClose}
|
||||
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
@@ -778,8 +704,8 @@ export const Controls: React.FC<Props> = ({
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={goToNextItem}
|
||||
onPress={goToNextItem}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react";
|
||||
import { useControlContext } from "./ControlContext";
|
||||
import { Track } from "../types";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
|
||||
interface VideoContextProps {
|
||||
audioTracks: TrackInfo[] | null;
|
||||
subtitleTracks: TrackInfo[] | null;
|
||||
audioTracks: Track[] | null;
|
||||
subtitleTracks: Track[] | null;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
@@ -24,14 +16,8 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||
|
||||
interface VideoProviderProps {
|
||||
children: ReactNode;
|
||||
getAudioTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
getSubtitleTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
|
||||
getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
@@ -45,30 +31,135 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
||||
null
|
||||
);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
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(() => {
|
||||
const fetchTracks = async () => {
|
||||
if (
|
||||
getSubtitleTracks &&
|
||||
(subtitleTracks === null || subtitleTracks.length === 0)
|
||||
) {
|
||||
const subtitles = await getSubtitleTracks();
|
||||
console.log("Getting embeded subtitles...", subtitles);
|
||||
if (getSubtitleTracks) {
|
||||
const subtitleData = await getSubtitleTracks();
|
||||
|
||||
// Step 1: Move external subs to the end, because VLC puts external subs at the end
|
||||
const sortedSubs = allSubs.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal));
|
||||
|
||||
// Step 2: Apply VLC indexing logic
|
||||
let textSubIndex = 0;
|
||||
const processedSubs: Track[] = sortedSubs?.map((sub) => {
|
||||
// Always increment for non-transcoding subtitles
|
||||
// Only increment for text-based subtitles when transcoding
|
||||
const shouldIncrement = !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
||||
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
||||
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
|
||||
|
||||
if (shouldIncrement) textSubIndex++;
|
||||
return {
|
||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||
index: sub.Index ?? -1,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Step 3: Restore the original order
|
||||
const subtitles: Track[] = processedSubs.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Add a "Disable Subtitles" option
|
||||
subtitles.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () =>
|
||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||
? setTrackParams("subtitle", -1, -1)
|
||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||
});
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
if (
|
||||
getAudioTracks &&
|
||||
(audioTracks === null || audioTracks.length === 0)
|
||||
) {
|
||||
const audio = await getAudioTracks();
|
||||
setAudioTracks(audio);
|
||||
if (getAudioTracks) {
|
||||
const audioData = await getAudioTracks();
|
||||
|
||||
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();
|
||||
|
||||
121
components/video-player/controls/dropdown/DropdownView.tsx
Normal file
121
components/video-player/controls/dropdown/DropdownView.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { TouchableOpacity, Platform } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
|
||||
const DropdownView = () => {
|
||||
const videoContext = useVideoContext();
|
||||
const { subtitleTracks, audioTracks } = videoContext;
|
||||
const ControlContext = useControlContext();
|
||||
const [item, mediaSource] = [ControlContext?.item, ControlContext?.mediaSource];
|
||||
const router = useRouter();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const changeBitrate = useCallback(
|
||||
(bitrate: string) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrate.toString(),
|
||||
}).toString();
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
},
|
||||
[item, mediaSource, subtitleIndex, audioIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="qualitytrigger">Quality</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{BITRATES?.map((bitrate, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`quality-item-${idx}`}
|
||||
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
||||
onValueChange={() => changeBitrate(bitrate.value?.toString() ?? "")}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{bitrate.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="subtitle-trigger">Subtitle</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{subtitleTracks?.map((sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`subtitle-item-${idx}`}
|
||||
value={subtitleIndex === sub.index.toString()}
|
||||
onValueChange={() => sub.setTrack()}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>{sub.name}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">Audio</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => track.setTrack()}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{track.name}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownView;
|
||||
@@ -1,158 +0,0 @@
|
||||
import React, { 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 { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
|
||||
interface DropdownViewDirectProps {
|
||||
showControls: boolean;
|
||||
offline?: boolean; // used to disable external subs for downloads
|
||||
}
|
||||
|
||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||
showControls,
|
||||
offline = false,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const ControlContext = useControlContext();
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
const item = ControlContext?.item;
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
|
||||
const videoContext = useVideoContext();
|
||||
const {
|
||||
subtitleTracks,
|
||||
audioTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
} = videoContext;
|
||||
|
||||
const allSubtitleTracksForDirectPlay = useMemo(() => {
|
||||
if (mediaSource?.TranscodingUrl) return null;
|
||||
const embeddedSubs =
|
||||
subtitleTracks
|
||||
?.map((s) => ({
|
||||
name: s.name,
|
||||
index: s.index,
|
||||
deliveryUrl: undefined,
|
||||
}))
|
||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
||||
|
||||
const externalSubs =
|
||||
mediaSource?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
||||
).map((s) => ({
|
||||
name: s.DisplayTitle! + " [External]",
|
||||
index: s.Index!,
|
||||
deliveryUrl: s.DeliveryUrl,
|
||||
})) || [];
|
||||
|
||||
// Combine embedded subs with external subs only if not offline
|
||||
if (!offline) {
|
||||
return [...embeddedSubs, ...externalSubs] as (
|
||||
| EmbeddedSubtitle
|
||||
| ExternalSubtitle
|
||||
)[];
|
||||
}
|
||||
return embeddedSubs as EmbeddedSubtitle[];
|
||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
||||
|
||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
||||
Subtitle
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`subtitle-item-${idx}`}
|
||||
value={subtitleIndex === sub.index.toString()}
|
||||
onValueChange={() => {
|
||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||
setSubtitleURL &&
|
||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
||||
} else {
|
||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||
}
|
||||
router.setParams({
|
||||
subtitleIndex: sub.index.toString(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||
{sub.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
||||
Audio
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{audioTracks?.map((track, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`audio-item-${idx}`}
|
||||
value={audioIndex === track.index.toString()}
|
||||
onValueChange={() => {
|
||||
setAudioTrack && setAudioTrack(track.index);
|
||||
router.setParams({
|
||||
audioIndex: track.index.toString(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{track.name}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownViewDirect;
|
||||
@@ -1,228 +0,0 @@
|
||||
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,7 +13,14 @@ type ExternalSubtitle = {
|
||||
type TranscodedSubtitle = {
|
||||
name: string;
|
||||
index: number;
|
||||
deliveryUrl: string;
|
||||
IsTextSubtitleStream: boolean;
|
||||
};
|
||||
|
||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
||||
type Track = {
|
||||
name: string;
|
||||
index: number;
|
||||
setTrack: () => void;
|
||||
};
|
||||
|
||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
TrackInfo,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const Colors = {
|
||||
primary: "#9334E9",
|
||||
primaryRGB: "rgb(147 51 234)",
|
||||
primaryLightRGB: "rgb(192 132 252)",
|
||||
text: "#ECEDEE",
|
||||
background: "#151718",
|
||||
tint: "#fff",
|
||||
|
||||
6
eas.json
6
eas.json
@@ -32,20 +32,20 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.25.0",
|
||||
"channel": "0.27.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.25.0",
|
||||
"channel": "0.27.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"channel": "0.25.0",
|
||||
"channel": "0.27.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
--- expo.js.original 2024-11-10 09:08:19
|
||||
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
|
||||
@@ -19,10 +19,8 @@
|
||||
const {
|
||||
barStyle
|
||||
} = androidStatusBar;
|
||||
+ const android = props?.android || {};
|
||||
const {
|
||||
- android = {}
|
||||
- } = props;
|
||||
- const {
|
||||
parentTheme = "Default"
|
||||
} = android;
|
||||
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
|
||||
\ No newline at end of file
|
||||
@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
|
||||
(x) => x.Type === "Audio"
|
||||
)?.Index;
|
||||
|
||||
// 4. Get default bitrate
|
||||
const bitrate = BITRATES[0];
|
||||
// 4. Get default bitrate from settings or fallback to max
|
||||
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||
|
||||
return {
|
||||
defaultAudioIndex:
|
||||
|
||||
109
hooks/useFavorite.ts
Normal file
109
hooks/useFavorite.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
|
||||
export const useFavorite = (item: BaseItemDto) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const type = "item";
|
||||
const [isFavorite, setIsFavorite] = useState(item.UserData?.IsFavorite);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFavorite(item.UserData?.IsFavorite);
|
||||
}, [item.UserData?.IsFavorite]);
|
||||
|
||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
||||
[type, item.Id],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const markFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).markFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
setIsFavorite(true);
|
||||
},
|
||||
});
|
||||
|
||||
const unmarkFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
setIsFavorite(false);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFavorite = () => {
|
||||
if (isFavorite) {
|
||||
unmarkFavoriteMutation.mutate();
|
||||
} else {
|
||||
markFavoriteMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isFavorite,
|
||||
toggleFavorite,
|
||||
markFavoriteMutation,
|
||||
unmarkFavoriteMutation,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||
import { Results } from "@/utils/jellyseerr/server/models/Search";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { inRange } from "lodash";
|
||||
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import {MediaRequestBody, RequestResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import {
|
||||
SeasonWithEpisodes,
|
||||
@@ -227,6 +227,23 @@ export class JellyseerrApi {
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async getRequest(id: number): Promise<MediaRequest> {
|
||||
return this.axios
|
||||
?.get<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST + `/${id}`)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async requests(params = {
|
||||
filter: "all",
|
||||
take: 10,
|
||||
sort: "modified",
|
||||
skip: 0
|
||||
}): Promise<RequestResultsResponse> {
|
||||
return this.axios
|
||||
?.get<RequestResultsResponse>(Endpoints.API_V1 + Endpoints.REQUEST, {params})
|
||||
.then(({data}) => data);
|
||||
}
|
||||
|
||||
async movieDetails(id: number) {
|
||||
return this.axios
|
||||
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
|
||||
@@ -439,22 +456,56 @@ export const useJellyseerr = () => {
|
||||
);
|
||||
|
||||
const isJellyseerrResult = (
|
||||
items: any[] | null | undefined
|
||||
): items is Results[] => {
|
||||
items: any | null | undefined
|
||||
): items is Results => {
|
||||
return (
|
||||
!items ||
|
||||
(items.length >= 0 &&
|
||||
Object.hasOwn(items[0], "mediaType") &&
|
||||
Object.values(MediaType).includes(items[0]["mediaType"]))
|
||||
);
|
||||
items &&
|
||||
Object.hasOwn(items, "mediaType") &&
|
||||
Object.values(MediaType).includes(items["mediaType"])
|
||||
)
|
||||
};
|
||||
|
||||
const getTitle = (item: TvResult | TvDetails | MovieResult | MovieDetails) => {
|
||||
return isJellyseerrResult(item)
|
||||
? (item.mediaType == MediaType.MOVIE ? item?.originalTitle : item?.name)
|
||||
: (item.mediaInfo.mediaType == MediaType.MOVIE ? (item as MovieDetails)?.title : (item as TvDetails)?.name)
|
||||
};
|
||||
|
||||
const getYear = (item: TvResult | TvDetails | MovieResult | MovieDetails) => {
|
||||
return new Date((
|
||||
isJellyseerrResult(item)
|
||||
? (item.mediaType == MediaType.MOVIE ? item?.releaseDate : item?.firstAirDate)
|
||||
: (item.mediaInfo.mediaType == MediaType.MOVIE ? (item as MovieDetails)?.releaseDate : (item as TvDetails)?.firstAirDate))
|
||||
|| ""
|
||||
)?.getFullYear?.()
|
||||
};
|
||||
|
||||
const getMediaType = (item: TvResult | TvDetails | MovieResult | MovieDetails): MediaType => {
|
||||
return isJellyseerrResult(item)
|
||||
? item.mediaType
|
||||
: item?.mediaInfo?.mediaType
|
||||
};
|
||||
|
||||
const jellyseerrRegion = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
);
|
||||
|
||||
const jellyseerrLocale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
return {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
isJellyseerrResult,
|
||||
getTitle,
|
||||
getYear,
|
||||
getMediaType,
|
||||
jellyseerrRegion,
|
||||
jellyseerrLocale,
|
||||
requestMedia,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||
const FFMPEGKitReactNative = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
|
||||
const FFMPEGKitReactNative = !Platform.isTV
|
||||
? require("ffmpeg-kit-react-native")
|
||||
: null;
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
@@ -24,8 +26,10 @@ import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
|
||||
type Statistics = typeof FFMPEGKitReactNative.Statistics
|
||||
const FFmpegKit = FFMPEGKitReactNative.FFmpegKit;
|
||||
type Statistics = typeof FFMPEGKitReactNative.Statistics;
|
||||
const FFmpegKit = Platform.isTV
|
||||
? null
|
||||
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
|
||||
const createFFmpegCommand = (url: string, output: string) => [
|
||||
"-y", // overwrite output files without asking
|
||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||
@@ -101,7 +105,10 @@ export const useRemuxHlsToMp4 = () => {
|
||||
}
|
||||
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
||||
return prev.filter(
|
||||
(process: { itemId: string | undefined }) =>
|
||||
process.itemId !== item.Id
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -126,7 +133,7 @@ export const useRemuxHlsToMp4 = () => {
|
||||
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.map((process: { itemId: string | undefined; }) => {
|
||||
return prev.map((process: { itemId: string | undefined }) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
@@ -161,15 +168,18 @@ export const useRemuxHlsToMp4 = () => {
|
||||
// First lets save any important assets we want to present to the user offline
|
||||
await onSaveAssets(api, item);
|
||||
|
||||
toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), {
|
||||
action: {
|
||||
label: "Go to download",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_started_for", { item: item.Name }),
|
||||
{
|
||||
action: {
|
||||
label: "Go to download",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const job: JobStatus = {
|
||||
@@ -201,7 +211,10 @@ export const useRemuxHlsToMp4 = () => {
|
||||
Error: ${error.message}, Stack: ${error.stack}`
|
||||
);
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
|
||||
return prev.filter(
|
||||
(process: { itemId: string | undefined }) =>
|
||||
process.itemId !== item.Id
|
||||
);
|
||||
});
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
|
||||
33
hooks/useSessions.ts
Normal file
33
hooks/useSessions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export interface useSessionsProps {
|
||||
refetchInterval: number;
|
||||
activeWithinSeconds: number;
|
||||
}
|
||||
|
||||
export const useSessions = ({ refetchInterval = 5 * 1000, activeWithinSeconds = 360 }: useSessionsProps) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: activeWithinSeconds,
|
||||
});
|
||||
return response.data
|
||||
.filter((s) => s.NowPlayingItem)
|
||||
.sort((a, b) => (b.NowPlayingItem?.Name ?? "").localeCompare(a.NowPlayingItem?.Name ?? ""));
|
||||
},
|
||||
refetchInterval: refetchInterval,
|
||||
});
|
||||
|
||||
return { sessions: data, isLoading };
|
||||
};
|
||||
15
i18n.ts
15
i18n.ts
@@ -5,7 +5,12 @@ import de from "./translations/de.json";
|
||||
import en from "./translations/en.json";
|
||||
import es from "./translations/es.json";
|
||||
import fr from "./translations/fr.json";
|
||||
import it from "./translations/it.json";
|
||||
import ja from "./translations/ja.json";
|
||||
import nl from "./translations/nl.json";
|
||||
import sv from "./translations/sv.json";
|
||||
import zhCN from './translations/zh-CN.json';
|
||||
import zhTW from './translations/zh-TW.json';
|
||||
import { getLocales } from "expo-localization";
|
||||
|
||||
export const APP_LANGUAGES = [
|
||||
@@ -13,7 +18,12 @@ export const APP_LANGUAGES = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "Français", value: "fr" },
|
||||
{ label: "Italiano", value: "it" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "Nederlands", value: "nl" },
|
||||
{ label: "Svenska", value: "sv" },
|
||||
{ label: "简体中文", value: "zh-CN" },
|
||||
{ label: "繁體中文", value: "zh-TW" },
|
||||
];
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
@@ -23,7 +33,12 @@ i18n.use(initReactI18next).init({
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
fr: { translation: fr },
|
||||
it: { translation: it },
|
||||
ja: { translation: ja },
|
||||
nl: { translation: nl },
|
||||
sv: { translation: sv },
|
||||
"zh-CN": { translation: zhCN },
|
||||
"zh-TW": { translation: zhTW },
|
||||
},
|
||||
|
||||
lng: getLocales()[0].languageCode || "en",
|
||||
|
||||
6
login.yaml
Normal file
6
login.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# login.yaml
|
||||
|
||||
appId: your.app.id
|
||||
---
|
||||
- launchApp
|
||||
- tapOn: "Text on the screen"
|
||||
@@ -24,6 +24,12 @@ export type VideoLoadStartPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PipStartedPayload = {
|
||||
nativeEvent: {
|
||||
pipStarted: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
||||
|
||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
||||
@@ -33,7 +39,7 @@ export type VlcPlayerSource = {
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
externalTrack?: { name: string, DeliveryUrl: string };
|
||||
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
startPosition?: number;
|
||||
@@ -64,9 +70,11 @@ export type VlcPlayerViewProps = {
|
||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
||||
onPipStarted?: (event: PipStartedPayload) => void;
|
||||
};
|
||||
|
||||
export interface VlcPlayerViewRef {
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
@@ -6,16 +6,30 @@ import {
|
||||
VlcPlayerViewRef,
|
||||
VlcPlayerSource,
|
||||
} from "./VlcPlayer.types";
|
||||
import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
|
||||
import {Platform} from "react-native";
|
||||
|
||||
interface NativeViewRef extends VlcPlayerViewRef {
|
||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
||||
}
|
||||
|
||||
const NativeViewManager = requireNativeViewManager("VlcPlayer");
|
||||
const VLCViewManager = requireNativeViewManager("VlcPlayer");
|
||||
const VLC3ViewManager = requireNativeViewManager("VlcPlayer3");
|
||||
|
||||
// Create a forwarded ref version of the native view
|
||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => <NativeViewManager {...props} ref={ref} />
|
||||
(props, ref) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
if (Platform.OS === "ios" || Platform.isTVOS) {
|
||||
if (settings.defaultPlayer == VideoPlayer.VLC_3) {
|
||||
console.log("[Apple] Using Vlc Player 3")
|
||||
return <VLC3ViewManager {...props} ref={ref}/>
|
||||
}
|
||||
}
|
||||
console.log("Using default Vlc Player")
|
||||
return <VLCViewManager {...props} ref={ref}/>
|
||||
}
|
||||
);
|
||||
|
||||
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
@@ -23,6 +37,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture()
|
||||
},
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
@@ -96,6 +113,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
onVideoProgress,
|
||||
onVideoLoadEnd,
|
||||
onVideoError,
|
||||
onPipStarted,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -122,6 +140,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
onVideoStateChange={onVideoStateChange}
|
||||
onVideoProgress={onVideoProgress}
|
||||
onVideoError={onVideoError}
|
||||
onPipStarted={onPipStarted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
modules/index.ts
Normal file
27
modules/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
import {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./VlcPlayer.types";
|
||||
|
||||
export {
|
||||
VlcPlayerView,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
};
|
||||
6
modules/vlc-player-3/expo-module.config.json
Normal file
6
modules/vlc-player-3/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayer3Module"]
|
||||
}
|
||||
}
|
||||
23
modules/vlc-player-3/ios/VlcPlayer3.podspec
Normal file
23
modules/vlc-player-3/ios/VlcPlayer3.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'VlcPlayer3'
|
||||
s.version = '3.6.1b1'
|
||||
s.summary = 'A sample project summary'
|
||||
s.description = 'A sample project description'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'MobileVLCKit', s.version
|
||||
s.tvos.dependency 'TVVLCKit', s.version
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
71
modules/vlc-player-3/ios/VlcPlayer3Module.swift
Normal file
71
modules/vlc-player-3/ios/VlcPlayer3Module.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class VlcPlayer3Module: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("VlcPlayer3")
|
||||
View(VlcPlayer3View.self) {
|
||||
Prop("source") { (view: VlcPlayer3View, source: [String: Any]) in
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { (view: VlcPlayer3View, paused: Bool) in
|
||||
if paused {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayer3View) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { (view: VlcPlayer3View) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: VlcPlayer3View) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { (view: VlcPlayer3View) in
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: VlcPlayer3View, time: Int32) in
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: VlcPlayer3View, trackIndex: Int) in
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") {
|
||||
(view: VlcPlayer3View, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
388
modules/vlc-player-3/ios/VlcPlayer3View.swift
Normal file
388
modules/vlc-player-3/ios/VlcPlayer3View.swift
Normal file
@@ -0,0 +1,388 @@
|
||||
import ExpoModulesCore
|
||||
#if os(tvOS)
|
||||
import TVVLCKit
|
||||
#else
|
||||
import MobileVLCKit
|
||||
#endif
|
||||
import UIKit
|
||||
|
||||
class VlcPlayer3View: ExpoView {
|
||||
private var mediaPlayer: VLCMediaPlayer?
|
||||
private var videoView: UIView?
|
||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||
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 startPosition: Int32 = 0
|
||||
private var isMediaReady: Bool = false
|
||||
private var externalTrack: [String: String]?
|
||||
private var progressTimer: DispatchSourceTimer?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
var hasSource = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
setupNotifications()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupView() {
|
||||
DispatchQueue.main.async {
|
||||
self.backgroundColor = .black
|
||||
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() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func startPictureInPicture() { }
|
||||
|
||||
@objc func play() {
|
||||
self.mediaPlayer?.play()
|
||||
self.isPaused = false
|
||||
print("Play")
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
self.mediaPlayer?.pause()
|
||||
self.isPaused = true
|
||||
}
|
||||
|
||||
@objc func seekTo(_ time: Int32) {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
let wasPlaying = player.isPlaying
|
||||
if wasPlaying {
|
||||
self.pause()
|
||||
}
|
||||
|
||||
if let duration = player.media?.length.intValue {
|
||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
||||
|
||||
// If the specified time is greater than the duration, seek to the end
|
||||
let seekTime = time > duration ? duration - 1000 : time
|
||||
player.time = VLCTime(int: seekTime)
|
||||
|
||||
if wasPlaying {
|
||||
self.play()
|
||||
}
|
||||
self.updatePlayerState()
|
||||
} else {
|
||||
print("Error: Unable to retrieve video duration")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ source: [String: Any]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.hasSource {
|
||||
return
|
||||
}
|
||||
|
||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
initOptions.append("--start-time=\(self.startPosition)")
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
print("Error: Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
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
|
||||
if isNetwork {
|
||||
print("Loading network file: \(uri)")
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
print("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
media = VLCMedia(path: uri)
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Media options: \(mediaOptions)")
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.mediaPlayer?.media = media
|
||||
self.hasSource = true
|
||||
|
||||
if autoplay {
|
||||
print("Playing...")
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
||||
}
|
||||
|
||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||
guard let trackNames = mediaPlayer?.audioTrackNames,
|
||||
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) {
|
||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
||||
print(
|
||||
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
print("Error: Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
|
||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
||||
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))
|
||||
} else {
|
||||
print("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||
guard let mediaPlayer = self.mediaPlayer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let count = mediaPlayer.numberOfSubtitlesTracks
|
||||
print("Debug: Number of subtitle tracks: \(count)")
|
||||
|
||||
guard count > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tracks: [[String: Any]] = []
|
||||
|
||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
||||
{
|
||||
for (index, name) in zip(indexes, names) {
|
||||
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
|
||||
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
|
||||
} else {
|
||||
tracks.append(["name": name, "index": index.intValue])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Subtitle tracks: \(tracks)")
|
||||
return tracks
|
||||
}
|
||||
|
||||
@objc func stop(completion: (() -> Void)? = nil) {
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
isStopping = true
|
||||
|
||||
// If we're not on the main thread, dispatch to main thread
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.performStop(completion: completion)
|
||||
}
|
||||
} else {
|
||||
performStop(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
|
||||
}
|
||||
|
||||
private func performStop(completion: (() -> Void)? = nil) {
|
||||
// Stop the media player
|
||||
mediaPlayer?.stop()
|
||||
|
||||
// Remove observer
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clear the video view
|
||||
videoView?.removeFromSuperview()
|
||||
videoView = nil
|
||||
|
||||
// Release the media player
|
||||
mediaPlayer?.delegate = nil
|
||||
mediaPlayer = nil
|
||||
|
||||
isStopping = false
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func updateVideoProgress() {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
let currentTimeMs = player.time.intValue
|
||||
let durationMs = player.media?.length.intValue ?? 0
|
||||
|
||||
print("Debug: Current time: \(currentTimeMs)")
|
||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||
if player.isPlaying && !self.isMediaReady {
|
||||
self.isMediaReady = true
|
||||
// Set external track subtitle when starting.
|
||||
if let externalTrack = self.externalTrack {
|
||||
if let name = externalTrack["name"], !name.isEmpty {
|
||||
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
|
||||
self.setSubtitleURL(deliveryUrl, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Expo Events
|
||||
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Deinitialization
|
||||
|
||||
deinit {
|
||||
performStop()
|
||||
}
|
||||
}
|
||||
|
||||
extension VlcPlayer3View: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
// self?.updateVideoProgress()
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - lastProgressCall >= 1 {
|
||||
lastProgressCall = timeNow
|
||||
updateVideoProgress()
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
self.updatePlayerState()
|
||||
}
|
||||
|
||||
private func updatePlayerState() {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
let currentState = player.state
|
||||
|
||||
var stateInfo: [String: Any] = [
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
]
|
||||
|
||||
if player.isPlaying {
|
||||
stateInfo["isPlaying"] = true
|
||||
stateInfo["isBuffering"] = false
|
||||
stateInfo["state"] = "Playing"
|
||||
} else {
|
||||
stateInfo["isPlaying"] = false
|
||||
stateInfo["state"] = "Paused"
|
||||
}
|
||||
|
||||
if player.state == VLCMediaPlayerState.buffering {
|
||||
stateInfo["isBuffering"] = true
|
||||
stateInfo["state"] = "Buffering"
|
||||
} else if player.state == VLCMediaPlayerState.error {
|
||||
print("player.state ~ error")
|
||||
stateInfo["state"] = "Error"
|
||||
self.onVideoLoadEnd?(stateInfo)
|
||||
} else if player.state == VLCMediaPlayerState.opening {
|
||||
print("player.state ~ opening")
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
|
||||
if self.lastReportedState != currentState
|
||||
|| self.lastReportedIsPlaying != player.isPlaying
|
||||
{
|
||||
self.lastReportedState = currentState
|
||||
self.lastReportedIsPlaying = player.isPlaying
|
||||
self.onVideoStateChange?(stateInfo)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension VlcPlayer3View: VLCMediaDelegate {
|
||||
// Implement VLCMediaDelegate methods if needed
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerState {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .opening: return "Opening"
|
||||
case .buffering: return "Buffering"
|
||||
case .playing: return "Playing"
|
||||
case .paused: return "Paused"
|
||||
case .stopped: return "Stopped"
|
||||
case .ended: return "Ended"
|
||||
case .error: return "Error"
|
||||
case .esAdded: return "ESAdded"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
modules/vlc-player-3/src/VlcPlayer3Module.ts
Normal file
5
modules/vlc-player-3/src/VlcPlayer3Module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { requireNativeModule } from 'expo-modules-core';
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule('VlcPlayer3');
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Sun Nov 17 18:25:45 AEDT 2024
|
||||
gradle.version=8.9
|
||||
@@ -0,0 +1,38 @@
|
||||
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,5 +1,6 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import androidx.core.os.bundleOf
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
@@ -7,6 +8,18 @@ class VlcPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("VlcPlayer")
|
||||
|
||||
OnActivityEntersForeground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onResume(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
OnActivityEntersBackground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onPause(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
View(VlcPlayerView::class) {
|
||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
||||
view.setSource(source)
|
||||
@@ -26,9 +39,14 @@ class VlcPlayerModule : Module() {
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError"
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { view: VlcPlayerView ->
|
||||
view.play()
|
||||
}
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
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.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.Looper
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
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.views.ExpoView
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
import org.videolan.libvlc.LibVLC
|
||||
import org.videolan.libvlc.Media
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.MediaPlayer
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
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 mediaPlayer: MediaPlayer? = null
|
||||
@@ -26,10 +52,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var lastReportedState: Int? = null
|
||||
private var lastReportedIsPlaying: Boolean? = null
|
||||
private var media : Media? = null
|
||||
private var timeLeft: Long? = null
|
||||
|
||||
private val onVideoProgress by EventDispatcher()
|
||||
private val onVideoStateChange by EventDispatcher()
|
||||
private val onVideoLoadEnd by EventDispatcher()
|
||||
private val onPipStarted by EventDispatcher()
|
||||
|
||||
private var startPosition: Int? = 0
|
||||
private var isMediaReady: Boolean = false
|
||||
@@ -44,23 +72,146 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
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 {
|
||||
VLCManager.listeners.add(this)
|
||||
setupView()
|
||||
setupPiP()
|
||||
}
|
||||
|
||||
private fun setupView() {
|
||||
Log.d("VlcPlayerView", "Setting up view")
|
||||
log.debug("Setting up view")
|
||||
setBackgroundColor(android.graphics.Color.WHITE)
|
||||
videoLayout = VLCVideoLayout(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
videoLayout.keepScreenOn = true
|
||||
addView(videoLayout)
|
||||
Log.d("VlcPlayerView", "View setup complete")
|
||||
log.debug("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>) {
|
||||
log.debug("setting source $source")
|
||||
if (hasSource) {
|
||||
log.debug("Source already set. Resuming")
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
play()
|
||||
return
|
||||
@@ -85,12 +236,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.setEventListener(this)
|
||||
|
||||
Log.d("VlcPlayerView", "Loading network file: $uri")
|
||||
log.debug("Loading network file: $uri")
|
||||
media = Media(libVLC, Uri.parse(uri))
|
||||
mediaPlayer?.media = media
|
||||
|
||||
|
||||
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
|
||||
log.debug("Debug: Media options: $mediaOptions")
|
||||
// media.addOptions(mediaOptions)
|
||||
|
||||
// Apply subtitle options
|
||||
@@ -107,11 +258,17 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
hasSource = true
|
||||
|
||||
if (autoplay) {
|
||||
Log.d("VlcPlayerView", "Playing...")
|
||||
log.debug("Playing...")
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
fun startPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
currentActivity.enterPictureInPictureMode(getPipParams()!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun play() {
|
||||
mediaPlayer?.play()
|
||||
isPaused = false
|
||||
@@ -151,9 +308,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
||||
|
||||
println("getAudioTracks")
|
||||
println(mediaPlayer?.getAudioTracks())
|
||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
||||
|
||||
return trackDescriptions.map { trackDescription ->
|
||||
@@ -177,19 +332,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
// Debug statement to print the result
|
||||
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
|
||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
||||
println("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
println("onDetachedFromWindow")
|
||||
log.debug("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()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
|
||||
@@ -202,6 +370,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
override fun onEvent(event: MediaPlayer.Event) {
|
||||
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
|
||||
when (event.type) {
|
||||
MediaPlayer.Event.Playing,
|
||||
MediaPlayer.Event.Paused,
|
||||
@@ -223,35 +392,27 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
"target" to "null", // Replace with actual target if needed
|
||||
"currentTime" to player.time.toInt(),
|
||||
"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) {
|
||||
MediaPlayer.Event.Playing -> {
|
||||
stateInfo["isPlaying"] = true
|
||||
stateInfo["isBuffering"] = false
|
||||
MediaPlayer.Event.Playing ->
|
||||
stateInfo["state"] = "Playing"
|
||||
}
|
||||
MediaPlayer.Event.Paused -> {
|
||||
stateInfo["isPlaying"] = false
|
||||
MediaPlayer.Event.Paused ->
|
||||
stateInfo["state"] = "Paused"
|
||||
}
|
||||
MediaPlayer.Event.Buffering -> {
|
||||
stateInfo["isBuffering"] = true
|
||||
MediaPlayer.Event.Buffering ->
|
||||
stateInfo["state"] = "Buffering"
|
||||
}
|
||||
MediaPlayer.Event.EncounteredError -> {
|
||||
Log.e("VlcPlayerView", "player.state ~ error")
|
||||
stateInfo["state"] = "Error"
|
||||
onVideoLoadEnd(stateInfo);
|
||||
}
|
||||
MediaPlayer.Event.Opening -> {
|
||||
Log.d("VlcPlayerView", "player.state ~ opening")
|
||||
MediaPlayer.Event.Opening ->
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
||||
lastReportedState = currentState
|
||||
lastReportedIsPlaying = player.isPlaying
|
||||
@@ -283,4 +444,23 @@ 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,7 +1,8 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayerModule"]
|
||||
"modules": ["VlcPlayerModule"],
|
||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import {
|
||||
NativeModulesProxy,
|
||||
EventEmitter,
|
||||
Subscription,
|
||||
} from "expo-modules-core";
|
||||
|
||||
import VlcPlayerModule from "./src/VlcPlayerModule";
|
||||
import VlcPlayerView from "./src/VlcPlayerView";
|
||||
import {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./src/VlcPlayer.types";
|
||||
|
||||
const emitter = new EventEmitter(
|
||||
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
|
||||
);
|
||||
|
||||
export function addPlaybackStateListener(
|
||||
listener: (event: PlaybackStatePayload) => void
|
||||
): Subscription {
|
||||
return emitter.addListener<PlaybackStatePayload>(
|
||||
"onPlaybackStateChanged",
|
||||
listener
|
||||
);
|
||||
}
|
||||
|
||||
export function addVideoLoadStartListener(
|
||||
listener: (event: VideoLoadStartPayload) => void
|
||||
): Subscription {
|
||||
return emitter.addListener<VideoLoadStartPayload>(
|
||||
"onVideoLoadStart",
|
||||
listener
|
||||
);
|
||||
}
|
||||
|
||||
export function addVideoStateChangeListener(
|
||||
listener: (event: VideoStateChangePayload) => void
|
||||
): Subscription {
|
||||
return emitter.addListener<VideoStateChangePayload>(
|
||||
"onVideoStateChange",
|
||||
listener
|
||||
);
|
||||
}
|
||||
|
||||
export function addVideoProgressListener(
|
||||
listener: (event: VideoProgressPayload) => void
|
||||
): Subscription {
|
||||
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
||||
}
|
||||
|
||||
export {
|
||||
VlcPlayerView,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
};
|
||||
32
modules/vlc-player/ios/AppLifecycleDelegate.swift
Normal file
32
modules/vlc-player/ios/AppLifecycleDelegate.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user