Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
6752888bb0 wip 2024-10-11 16:42:13 +02:00
269 changed files with 6996 additions and 18333 deletions

26
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,26 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: '❌ bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone15Pro]
- OS: [e.g. iOS18]
- Version [e.g. 0.3.1]

View File

@@ -1,59 +0,0 @@
name: Bug report
description: Create a report to help us improve
title: "[Bug]: "
labels:
- ["❌ bug"]
projects:
- ["streamyfin/3"]
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: device
attributes:
label: Which device and operating system are you using?
description: e.g. iPhone 15, iOS 18.1.1
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.25.0
- 0.24.0
- 0.23.0
- 0.22.0
- 0.21.0
- older
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: If applicable, please add screenshots to help explain your problem.
You can drag and drop images here or paste them directly into the comment box.

View File

@@ -4,8 +4,7 @@ about: Suggest an idea for this project
title: '' title: ''
labels: '✨ enhancement' labels: '✨ enhancement'
assignees: '' assignees: ''
projects:
- streamyfin/3
--- ---
**Describe the solution you'd like** **Describe the solution you'd like**

View File

@@ -1,49 +0,0 @@
name: Automatic Build and Deploy
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build:
runs-on: macos-15
name: Build IOS
steps:
- uses: actions/checkout@v2
name: Check out repository
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: |
bun i && bun run submodule-reload
npx expo prebuild
- uses: sparkfabrik/ios-build-action@v2.3.0
with:
upload-to-testflight: false
increment-build-number: false
build-pods: true
pods-path: "ios/Podfile"
configuration: Release
# Change later to app-store if wanted
export-method: appstore
#export-method: ad-hoc
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
project-path: "ios/Streamyfin.xcodeproj"
scheme: Streamyfin
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
team-id: ${{ secrets.TEAM_ID }}
team-name: ${{ secrets.TEAM_NAME }}
#match-password: ${{ secrets.MATCH_PASSWORD }}
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
#match-build-type: "appstore"
#browserstack-upload: true
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
#fastlane-env: stage
ios-app-id: com.stetsed.teststreamyfin
output-path: build-${{ github.sha }}.ipa

View File

@@ -1,18 +0,0 @@
name: Discord Pull Request Notification
on:
pull_request:
types: [opened, reopened]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: joelwmale/webhook-action@master
with:
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
body: |
{
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
}

7
.gitignore vendored
View File

@@ -9,7 +9,6 @@ npm-debug.*
*.mobileprovision *.mobileprovision
*.orig.* *.orig.*
web-build/ web-build/
modules/vlc-player/android/build
# macOS # macOS
.DS_Store .DS_Store
@@ -27,14 +26,8 @@ package-lock.json
/ios /ios
/android /android
modules/player/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json credentials.json
*.apk *.apk
*.ipa *.ipa
.continuerc.json .continuerc.json
.vscode/
.idea/
.ruby-lsp

4
.gitmodules vendored
View File

@@ -1,4 +0,0 @@
[submodule "utils/jellyseerr"]
path = utils/jellyseerr
url = https://github.com/herrrta/jellyseerr
branch = models

View File

@@ -8,8 +8,5 @@
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
},
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
} }
} }

View File

@@ -8,17 +8,17 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img width=150 src="./assets/images/screenshots/screenshot1.png" />
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> <img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" /> <img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=159 src="./assets/images/jellyseerr.PNG"/>
</div> </div>
## 🌟 Features ## 🌟 Features
- 🚀 **Skip Intro / Credits Support** - 🚀 **Skp intro / credits support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. - 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
## 🧪 Experimental Features ## 🧪 Experimental Features
@@ -32,17 +32,22 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features. Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
### Streamyfin Plugin ## Plugins
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: In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
- Auto log in to Jellyseerr without the user having to do anythin ### Collection rows
- Choose the default languages
- Set download method and search provider
- Customize homescreen
- And more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) Jellyfin collections can be shown as rows or carousel on the home screen.
The following tags can be added to a collection to provide this functionality.
Available tags:
- sf_promoted: will make the collection a row at home
- sf_carousel: will make the collection a carousel on home.
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
### Jellysearch ### Jellysearch
@@ -61,13 +66,15 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a> <a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div> </div>
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android. Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
### Beta testing ### Beta testing
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. Get the latest updates by using the TestFlight version of the app.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas. <a href="https://testflight.apple.com/join/CWBaAAK2">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
## 🚀 Getting Started ## 🚀 Getting Started
@@ -82,10 +89,36 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
### Development info ### Development info
1. Use node `>20` 1. Use node `20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i`
3. Make sure you have xcode and/or android studio installed. 3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app.
## Extended chromecast controls
Add this to AppDelegate.mm:
```
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
#if __has_include(<GoogleCast/GoogleCast.h>)
...
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;`
#endif
```
Add this to Info.plist:
```
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
```
## 📄 License ## 📄 License
@@ -103,7 +136,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us ## 🌐 Connect with Us
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY) Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
If you have questions or need support, feel free to reach out: If you have questions or need support, feel free to reach out:
@@ -112,7 +145,7 @@ If you have questions or need support, feel free to reach out:
## 📝 Credits ## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries. Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
## ✨ Acknowledgements ## ✨ Acknowledgements
@@ -120,9 +153,8 @@ I'd like to thank the following people and projects for their contributions to S
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
- The Jellyfin devs for always being helpful in the Discord. - The Jellyfin devs for always being helpful in the Discord.
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) [![Star History Chart](https://api.star-history.com/svg?repos=fredrikburmester/streamyfin&type=Date)](https://star-history.com/#fredrikburmester/streamyfin&Date)

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.25.0", "version": "0.18.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -23,10 +23,7 @@
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
}, }
"UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true
}, },
"config": { "config": {
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
@@ -36,15 +33,14 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 50, "versionCode": 46,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png" "foregroundImage": "./assets/images/adaptive_icon.png"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
"android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
"android.permission.WRITE_SETTINGS"
] ]
}, },
"plugins": [ "plugins": [
@@ -70,12 +66,18 @@
} }
} }
], ],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[ [
"expo-build-properties", "expo-build-properties",
{ {
"ios": { "ios": {
"deploymentTarget": "15.6", "deploymentTarget": "15.6"
"useFrameworks": "static"
}, },
"android": { "android": {
"android": { "android": {
@@ -104,17 +106,7 @@
{ {
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
} }
], ]
"expo-localization",
"expo-asset",
[
"react-native-edge-to-edge",
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withGoogleCastActivity.js"],
["./plugins/withTrustLocalCerts.js"]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@@ -1,22 +0,0 @@
import {Stack} from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function CustomMenuLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: t("tabs.custom_links"),
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -1,77 +0,0 @@
import { FlatList, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useCallback, useEffect, useState } from "react";
import { useAtom } from "jotai/index";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ListItem } from "@/components/list/ListItem";
import * as WebBrowser from "expo-web-browser";
import Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next";
export interface MenuLink {
name: string;
url: string;
icon: string;
}
export default function menuLinks() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
const { t } = useTranslation();
const getMenuLinks = useCallback(async () => {
try {
const response = await api?.axiosInstance.get(
api?.basePath + "/web/config.json"
);
const config = response?.data;
if (!config && !config.hasOwnProperty("menuLinks")) {
console.error("Menu links not found");
return;
}
setMenuLinks(config?.menuLinks as MenuLink[]);
} catch (error) {
console.error("Failed to retrieve config:", error);
}
}, [api]);
useEffect(() => {
getMenuLinks();
}, []);
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 10,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={menuLinks}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
<ListItem
title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white" />}
/>
</TouchableOpacity>
)}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
</View>
}
/>
);
}

View File

@@ -1,29 +0,0 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: t("tabs.favorites"),
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
</Stack>
);
}

View File

@@ -1,36 +0,0 @@
import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import React, { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function favorites() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false);
const refetch = useCallback(async () => {
setLoading(true);
await invalidateCache();
setLoading(false);
}, []);
const insets = useSafeAreaInsets();
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="my-4">
<Favorites />
</View>
</ScrollView>
);
}

View File

@@ -1,14 +1,14 @@
import { Chromecast } from "@/components/Chromecast"; import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons"; import { useDownload } from "@/providers/DownloadProvider";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -16,11 +16,8 @@ export default function IndexLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.home"), headerTitle: "Home",
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
@@ -30,6 +27,7 @@ export default function IndexLayout() {
onPress={() => { onPress={() => {
router.push("/(auth)/settings"); router.push("/(auth)/settings");
}} }}
className="p-2 "
> >
<Feather name="settings" color={"white"} size={22} /> <Feather name="settings" color={"white"} size={22} />
</TouchableOpacity> </TouchableOpacity>
@@ -38,59 +36,27 @@ export default function IndexLayout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name="downloads/index" name="downloads"
options={{ options={{
title: t("home.downloads.downloads_title"), title: "Downloads",
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
options={{
title: t("home.downloads.tvseries"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name="settings" name="settings"
options={{ options={{
title: t("home.settings.settings_title"), title: "Settings",
}} headerRight: () => (
/> <View className="">
<Stack.Screen <Ionicons
name="settings/optimized-server/page" name="file-tray-full-outline"
options={{ size={22}
title: "", color="white"
}} onPress={() => {
/> router.push("/logs");
<Stack.Screen }}
name="settings/marlin-search/page" />
options={{ </View>
title: "", ),
}}
/>
<Stack.Screen
name="settings/jellyseerr/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/hide-libraries/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/logs/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="intro/page"
options={{
headerShown: false,
title: "",
presentation: "modal",
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (

View File

@@ -0,0 +1,123 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => {
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
const [settings] = useSettings();
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
[downloadedFiles]
);
const groupedBySeries = useMemo(() => {
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
const series: { [key: string]: BaseItemDto[] } = {};
episodes?.forEach((e) => {
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
series[e.SeriesName!].push(e);
});
return Object.values(series);
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item: BaseItemDto) => (
<View className="mb-2 last:mb-0" key={item.Id}>
<MovieCard item={item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
<SeriesCard items={items} key={items[0].SeriesId} />
))}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
);
};
export default downloads;

View File

@@ -1,132 +0,0 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}
);
const { downloadedFiles, deleteItems } = useDownload();
const series = useMemo(() => {
try {
return (
downloadedFiles
?.filter((f) => f.item.SeriesId == seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
) || []
);
} catch {
return [];
}
}, [downloadedFiles]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
episodeSeasonIndex ||
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {};
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return (
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
[]
);
}, [series, seasonIndex]);
const initialSeasonIndex = useMemo(
() =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason]
);
useEffect(() => {
if (series.length > 0) {
navigation.setOptions({
title: series[0].item.SeriesName,
});
} else {
storage.delete(seriesId);
router.back();
}
}, [series]);
const deleteSeries = useCallback(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
]
);
}, [groupBySeason]);
return (
<View className="flex-1">
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2 px-4">
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View>
);
}

View File

@@ -1,253 +0,0 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { t } from 'i18next';
import { DownloadSize } from "@/components/downloads/DownloadSize";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
series[e.item.SeriesName!].push(e);
});
return Object.values(series);
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
),
});
}, [downloadedFiles]);
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const deleteShows = () =>
deleteFileByType("Episode")
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]);
return (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600">
{t("home.downloads.queue_hint")}
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">
{q.item.Type}
</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
)}
</View>
)}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View
className="mb-2 last:mb-0"
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color="purple" onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
<Button color="red" onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</>
);
}
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
t("home.downloads.new_app_version_requires_re_download_description"),
[
{
text: t("home.downloads.back"),
onPress: () => router.back(),
},
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
);
}

View File

@@ -5,10 +5,10 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { TAB_HEIGHT } from "@/constants/Values";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { HomeSectionStyle, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk"; import { Api } from "@jellyfin/sdk";
import { import {
@@ -23,13 +23,13 @@ import {
getUserViewsApi, getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query"; import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
TouchableOpacity, TouchableOpacity,
@@ -54,30 +54,21 @@ type MediaListSection = {
type Section = ScrollingCollectionListSection | MediaListSection; type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() { export default function index() {
const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ const [settings, _] = useSettings();
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null); const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false); const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles, cleanCacheDirectory } = useDownload(); const { downloadedFiles } = useDownload();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0; const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({ navigation.setOptions({
@@ -116,17 +107,13 @@ export default function index() {
setIsConnected(state.isConnected); setIsConnected(state.isConnected);
}); });
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, []); }, []);
const { const {
data, data: userViews,
isError: e1, isError: e1,
isLoading: l1, isLoading: l1,
} = useQuery({ } = useQuery({
@@ -146,10 +133,28 @@ export default function index() {
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
const userViews = useMemo( const {
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), data: mediaListCollections,
[data, settings?.hiddenLibraries] isError: e2,
); isLoading: l2,
} = useQuery({
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000,
});
const collections = useMemo(() => { const collections = useMemo(() => {
const allow = ["movies", "tvshows"]; const allow = ["movies", "tvshows"];
@@ -160,14 +165,28 @@ export default function index() {
); );
}, [userViews]); }, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
setLoading(true); setLoading(true);
await refreshStreamyfinPluginSettings(); await queryClient.invalidateQueries({
await invalidateCache(); queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["item"],
refetchType: "all",
type: "all",
exact: false,
});
setLoading(false); setLoading(false);
}, []); }, [queryClient]);
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(
( (
@@ -184,7 +203,7 @@ export default function index() {
( (
await getUserLibraryApi(api).getLatestMedia({ await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id, userId: user?.Id,
limit: 20, limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"], fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1, imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
@@ -199,160 +218,114 @@ export default function index() {
[api, user?.Id] [api, user?.Id]
); );
let sections: Section[] = []; const sections = useMemo(() => {
if (!settings?.home || !settings?.home?.sections) { if (!api || !user?.Id) return [];
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => { const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] = const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", {libraryName: c.Name}); const title = "Recently Added in " + c.Name;
const queryKey = [ const queryKey = [
"home", "home",
"recentlyAddedIn" + c.CollectionType, "recentlyAddedIn" + c.CollectionType,
user?.Id!, user?.Id!,
c.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; return createCollectionConfig(
}, [api, user?.Id, collections]); title || "",
} else { queryKey,
sections = useMemo(() => { includeItemTypes,
if (!api || !user?.Id) return []; c.Id
const ss: Section[] = []; );
});
for (const key in settings.home?.sections) { const ss: Section[] = [
const section = settings.home?.sections[key]; {
const id = section.title || key; title: "Continue Watching",
ss.push({ queryKey: ["home", "resumeItems", user.Id],
title: id, queryFn: async () =>
queryKey: ["home", id], (
queryFn: async () => { await getItemsApi(api).getResumeItems({
if (section.items) { userId: user.Id,
const response = await getItemsApi(api).getItems({ enableImageTypes: ["Primary", "Backdrop", "Thumb"],
userId: user?.Id, includeItemTypes: ["Movie", "Series", "Episode"],
limit: section.items?.limit || 25, })
recursive: true, ).data.Items || [],
includeItemTypes: section.items?.includeItemTypes, type: "ScrollingCollectionList",
sortBy: section.items?.sortBy, orientation: "horizontal",
sortOrder: section.items?.sortOrder, },
filters: section.items?.filters, {
parentId: section.items?.parentId, title: "Next Up",
}); queryKey: ["home", "nextUp-all", user?.Id],
return response.data.Items || []; queryFn: async () =>
} else if (section.nextUp) { (
const response = await getTvShowsApi(api).getNextUp({ await getTvShowsApi(api).getNextUp({
userId: user?.Id, userId: user?.Id,
fields: ["MediaSourceCount"], fields: ["MediaSourceCount"],
limit: section.items?.limit || 25, limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false, enableResumable: false,
enableRewatching: section.items?.enableRewatching || false, })
}); ).data.Items || [],
return response.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: "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: "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 []; return [];
}, }
type: "ScrollingCollectionList", },
orientation: section?.orientation || "vertical", type: "ScrollingCollectionList",
}); orientation: "horizontal",
} },
return ss; ];
}, [api, user?.Id, settings.home?.sections]); return ss;
} }, [api, user?.Id, collections, mediaListCollections]);
if (isConnected === false) { if (isConnected === false) {
return ( return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8"> <View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text> <Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70"> <Text className="text-center opacity-70">
{t("home.no_internet_message")} No worries, you can still watch{"\n"}downloaded content.
</Text> </Text>
<View className="mt-4"> <View className="mt-4">
<Button <Button
@@ -363,7 +336,7 @@ export default function index() {
<Ionicons name="arrow-forward" size={20} color="white" /> <Ionicons name="arrow-forward" size={20} color="white" />
} }
> >
{t("home.go_to_downloads")} Go to downloads
</Button> </Button>
<Button <Button
color="black" color="black"
@@ -389,15 +362,19 @@ export default function index() {
); );
} }
if (e1) const insets = useSafeAreaInsets();
if (e1 || e2)
return ( return (
<View className="flex flex-col items-center justify-center h-full -mt-6"> <View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text> <Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">{t("home.error_message")}</Text> <Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View> </View>
); );
if (l1) if (l1 || l2)
return ( return (
<View className="justify-center items-center h-full"> <View className="justify-center items-center h-full">
<Loader /> <Loader />
@@ -411,11 +388,15 @@ export default function index() {
refreshControl={ refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} /> <RefreshControl refreshing={loading} onRefresh={refetch} />
} }
key={"home"}
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 16, paddingBottom: 16,
}} }}
style={{
marginBottom: TAB_HEIGHT,
}}
> >
<View className="flex flex-col space-y-4"> <View className="flex flex-col space-y-4">
<LargeMovieCarousel /> <LargeMovieCarousel />

View File

@@ -1,135 +0,0 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import {useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native";
export default function page() {
const router = useRouter();
const { t } = useTranslation();
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, [])
);
return (
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
<View>
<Text className="text-3xl font-bold text-center mb-2">
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className="text-center">
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className="text-lg font-bold">{t("home.intro.features_title")}</Text>
<Text className="text-xs">
{t("home.intro.features_description")}
</Text>
<View className="flex flex-row items-center mt-4">
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Jellyseerr</Text>
<Text className="shrink text-xs">
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<Ionicons name="cloud-download-outline" size={32} color="white" />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">{t("home.intro.downloads_feature_title")}</Text>
<Text className="shrink text-xs">
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<Feather name="cast" size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Chromecast</Text>
<Text className="shrink text-xs">
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<Feather name="settings" size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">{t("home.intro.centralised_settings_plugin_title")}</Text>
<Text className="shrink text-xs">
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className="text-purple-600"
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
);
}}
>
{t("home.intro.read_more")}
</Text>
</Text>
</View>
</View>
</View>
<View>
<Button
onPress={() => {
router.back();
}}
className="mt-4"
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className="mt-4"
>
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

@@ -1,109 +1,128 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/ListItem";
import { ListItem } from "@/components/list/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles";
import { AudioToggles } from "@/components/settings/AudioToggles"; import { useDownload } from "@/providers/DownloadProvider";
import { DownloadSettings } from "@/components/settings/DownloadSettings"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { MediaProvider } from "@/components/settings/MediaContext"; import { clearLogs, readFromLog } from "@/utils/log";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { OtherSettings } from "@/components/settings/OtherSettings"; import { useQuery } from "@tanstack/react-query";
import { PluginSettings } from "@/components/settings/PluginSettings"; import * as Haptics from "expo-haptics";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { useAtom } from "jotai";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { Alert, ScrollView, View } from "react-native";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv"; import { toast } from "sonner-native";
export default function settings() { export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success"); const { deleteAllFiles } = useDownload();
const onClearLogsClicked = async () => { const [api] = useAtom(apiAtom);
clearLogs(); const [user] = useAtom(userAtom);
successHapticFeedback();
const insets = useSafeAreaInsets();
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
"Enter the quick connect code",
async (text) => {
if (text) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: text,
userId: user?.Id,
});
if (res.status === 200) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Alert.alert("Success", "Quick connect authorized");
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
}
}
);
}; };
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => {
logout();
}}
>
<Text className="text-red-600">{t("home.settings.log_out_button")}</Text>
</TouchableOpacity>
),
});
}, []);
return ( return (
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 100,
}} }}
> >
<View className="p-4 flex flex-col gap-y-4"> <View className="p-4 flex flex-col gap-y-4">
<UserInfo /> {/* <Button
<QuickConnect className="mb-4" /> onPress={() => {
registerBackgroundFetchAsync();
}}
>
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">User Info</Text>
<MediaProvider> <View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<MediaToggles className="mb-4" /> <ListItem title="User" subTitle={user?.Name} />
<AudioToggles className="mb-4" /> <ListItem title="Server" subTitle={api?.basePath} />
<SubtitleToggles className="mb-4" /> <ListItem title="Token" subTitle={api?.accessToken} />
</MediaProvider> </View>
<OtherSettings />
<DownloadSettings />
<PluginSettings />
<AppLanguageSelector/>
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor="red"
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className="mb-4">
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
title={t("home.settings.logs.logs_title")}
/>
<ListItem
textColor="red"
onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")}
/>
</ListGroup>
</View> </View>
<StorageSettings /> <View>
<Text className="font-bold text-lg mb-2">Quick connect</Text>
<Button onPress={openQuickConnectAuthCodeInput} color="black">
Authorize
</Button>
</View>
<SettingToggles />
<View>
<Text className="font-bold text-lg mb-2">Account and storage</Text>
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs
</Button>
</View>
</View>
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -1,67 +0,0 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Switch, View } from "react-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
});
if (!settings) return null;
if (isLoading)
return (
<View className="mt-4">
<Loader />
</View>
);
return (
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4"
>
<ListGroup>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({
hiddenLibraries: value
? [...(settings.hiddenLibraries || []), view.Id!]
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);
}

View File

@@ -1,16 +0,0 @@
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
>
<JellyseerrSettings />
</DisabledSetting>
);
}

View File

@@ -1,35 +0,0 @@
import { Text } from "@/components/common/Text";
import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
export default function page() {
const { logs } = useLog();
const { t } = useTranslation();
return (
<ScrollView className="p-4">
<View className="flex flex-col space-y-2">
{logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<Text
className={`
mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
)}
</View>
</ScrollView>
);
}

View File

@@ -1,117 +0,0 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useTranslation } from "react-i18next";
import React, {useEffect, useMemo, useState} from "react";
import {
Linking,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<DisabledSetting
disabled={disabled}
className="px-4"
>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
>
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
);
}

View File

@@ -1,89 +0,0 @@
import { Text } from "@/components/common/Text";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { getStatistics } from "@/utils/optimize-server";
import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error(t("home.settings.toasts.invalid_url"));
return;
}
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
updateSettings({
optimizedVersionsServerUrl: updatedUrl,
});
return await getStatistics({
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(),
});
},
onSuccess: (data) => {
if (data) {
toast.success(t("home.settings.toasts.connected"));
} else {
toast.error(t("home.settings.toasts.could_not_connect"));
}
},
onError: () => {
toast.error(t("home.settings.toasts.could_not_connect"));
},
});
const onSave = (newVal: string) => {
saveMutation.mutate(newVal);
};
useEffect(() => {
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
navigation.setOptions({
title: t("home.settings.downloads.optimized_server"),
headerRight: () =>
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
</TouchableOpacity>
),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</DisabledSetting>
);
}

View File

@@ -18,12 +18,10 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { actorId } = local as { actorId: string }; const { actorId } = local as { actorId: string };
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -112,7 +110,7 @@ const page: React.FC = () => {
</View> </View>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100"> <Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
{t("item_card.appeared_in")} Appeared In
</Text> </Text>
<InfiniteHorizontalScroll <InfiniteHorizontalScroll
height={247} height={247}

View File

@@ -0,0 +1,128 @@
import { Chromecast } from "@/components/Chromecast";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { SongsList } from "@/components/music/SongsList";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import ArtistPoster from "@/components/posters/ArtistPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId, artistId, albumId } = searchParams as {
collectionId: string;
artistId: string;
albumId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [albumId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!albumId,
staleTime: 0,
});
const {
data: songs,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["songs", artistId, albumId],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: albumId,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
const insets = useSafeAreaInsets();
if (!album) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={album}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
<Text className="text-neutral-500">
{songs?.TotalRecordCount} songs
</Text>
</View>
<View className="px-4">
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ParallaxScrollView>
);
}

View File

@@ -0,0 +1,130 @@
import ArtistPoster from "@/components/posters/ArtistPoster";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
export default function page() {
const searchParams = useLocalSearchParams();
const { artistId } = searchParams as {
artistId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [startIndex, setStartIndex] = useState<number>(0);
const { data: artist } = useQuery({
queryKey: ["album", artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [artistId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!artistId,
staleTime: 0,
});
const {
data: albums,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["albums", artistId, startIndex],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: artistId,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["MusicAlbum"],
recursive: true,
fields: [
"ParentId",
"PrimaryImageAspectRatio",
"ParentId",
"PrimaryImageAspectRatio",
],
collapseBoxSetItems: false,
albumArtistIds: [artistId],
startIndex,
limit: 100,
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
const insets = useSafeAreaInsets();
if (!artist || !albums) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={artist}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
<Text className="text-neutral-500">
{albums.TotalRecordCount} albums
</Text>
</View>
<View className="flex flex-row flex-wrap justify-between px-4">
{albums.Items.map((item, idx) => (
<TouchableItemRouter
item={item}
style={{ width: "30%", marginBottom: 20 }}
key={idx}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
))}
</View>
</ParallaxScrollView>
);
}

View File

@@ -0,0 +1,117 @@
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { FlatList, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getArtistsApi(api).getArtists({
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
parentId: collectionId,
userId: user?.Id,
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection?.Id && !!api && !!user?.Id,
});
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
if (!data) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">Artists</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableItemRouter
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
item={item}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -33,7 +33,6 @@ import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -46,8 +45,6 @@ const page: React.FC = () => {
ScreenOrientation.Orientation.PORTRAIT_UP ScreenOrientation.Orientation.PORTRAIT_UP
); );
const { t } = useTranslation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
@@ -107,12 +104,9 @@ const page: React.FC = () => {
"CanDelete", "CanDelete",
"MediaSourceCount", "MediaSourceCount",
], ],
// true is needed for merged versions
recursive: true,
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)), years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: ["Movie", "Series"],
}); });
return response.data || null; return response.data || null;
@@ -247,7 +241,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title={t("library.filters.genres")} title="Genres"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -274,7 +268,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title={t("library.filters.years")} title="Years"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -299,7 +293,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title={t("library.filters.tags")} title="Tags"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -317,7 +311,7 @@ const page: React.FC = () => {
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title={t("library.filters.sort_by")} title="Sort By"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -337,7 +331,7 @@ const page: React.FC = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title={t("library.filters.sort_order")} title="Sort Order"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -377,7 +371,7 @@ const page: React.FC = () => {
<FlashList <FlashList
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text> <Text className="font-bold text-xl text-neutral-500">No results</Text>
</View> </View>
} }
extraData={[ extraData={[

View File

@@ -0,0 +1,95 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { View } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string };
const { data: item, isError } = useQuery({
queryKey: ["item", id],
queryFn: async () => {
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: id,
});
return res;
},
enabled: !!id && !!api,
staleTime: 60 * 1000 * 5, // 5 minutes
});
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
const fadeIn = (callback: any) => {
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
useEffect(() => {
if (item) {
fadeOut(() => {});
} else {
fadeIn(() => {});
}
}, [item]);
if (isError)
return (
<View className="flex flex-col items-center justify-center h-screen w-screen">
<Text>Could not load item</Text>
</View>
);
return (
<View className="flex flex-1 relative">
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
>
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
</Animated.View>
{item && <ItemContent item={item} />}
</View>
);
};
export default Page;

View File

@@ -1,23 +1,14 @@
import { ItemImage } from "@/components/common/ItemImage"; import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { HourHeader } from "@/components/livetv/HourHeader"; import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow"; import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { TAB_HEIGHT } from "@/constants/Values"; import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { import { Button, Dimensions, ScrollView, View } from "react-native";
Button,
Dimensions,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30; const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
@@ -87,6 +78,8 @@ export default function page() {
const screenWidth = Dimensions.get("window").width; const screenWidth = Dimensions.get("window").width;
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
const [scrollX, setScrollX] = useState(0); const [scrollX, setScrollX] = useState(0);
const handleNextPage = useCallback(() => { const handleNextPage = useCallback(() => {
@@ -107,15 +100,24 @@ export default function page() {
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 16, paddingBottom: 16,
}} }}
style={{
marginBottom: TAB_HEIGHT,
}}
> >
<PageButtons <View className="flex flex-row bg-neutral-800 w-full items-end">
currentPage={currentPage} <Button
onPrevPage={handlePrevPage} title="Previous"
onNextPage={handleNextPage} onPress={handlePrevPage}
isNextDisabled={ disabled={currentPage === 1}
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE />
} <Button
/> title="Next"
onPress={handleNextPage}
disabled={
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
}
/>
</View>
<View className="flex flex-row"> <View className="flex flex-row">
<View className="flex flex-col w-[64px]"> <View className="flex flex-col w-[64px]">
@@ -164,58 +166,3 @@ export default function page() {
</ScrollView> </ScrollView>
); );
} }
interface PageButtonsProps {
currentPage: number;
onPrevPage: () => void;
onNextPage: () => void;
isNextDisabled: boolean;
}
const PageButtons: React.FC<PageButtonsProps> = ({
currentPage,
onPrevPage,
onNextPage,
isNextDisabled,
}) => {
const { t } = useTranslation();
return (
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<TouchableOpacity
onPress={onPrevPage}
disabled={currentPage === 1}
className="flex flex-row items-center"
>
<Ionicons
name="chevron-back"
size={24}
color={currentPage === 1 ? "gray" : "white"}
/>
<Text
className={`ml-1 ${
currentPage === 1 ? "text-gray-500" : "text-white"
}`}
>
{t("live_tv.previous")}
</Text>
</TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text>
<TouchableOpacity
onPress={onNextPage}
disabled={isNextDisabled}
className="flex flex-row items-center"
>
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
>
{t("live_tv.next")}
</Text>
<Ionicons
name="chevron-forward"
size={24}
color={isNextDisabled ? "gray" : "white"}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -5,17 +5,17 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { ScrollView, View } from "react-native"; import {
ScrollView,
View
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
@@ -27,11 +27,14 @@ export default function page() {
paddingBottom: 16, paddingBottom: 16,
paddingTop: 8, paddingTop: 8,
}} }}
style={{
marginBottom: TAB_HEIGHT,
}}
> >
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "recommended"]} queryKey={["livetv", "recommended"]}
title={t("live_tv.on_now")} title={"On now"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({ const res = await getLiveTvApi(api).getRecommendedPrograms({
@@ -49,7 +52,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "shows"]} queryKey={["livetv", "shows"]}
title={t("live_tv.shows")} title={"Shows"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -71,7 +74,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "movies"]} queryKey={["livetv", "movies"]}
title={t("live_tv.movies")} title={"Movies"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -89,7 +92,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "sports"]} queryKey={["livetv", "sports"]}
title={t("live_tv.sports")} title={"Sports"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -107,7 +110,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "kids"]} queryKey={["livetv", "kids"]}
title={t("live_tv.for_kids")} title={"For Kids"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -125,7 +128,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "news"]} queryKey={["livetv", "news"]}
title={t("live_tv.news")} title={"News"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({

View File

@@ -1,13 +1,11 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import React from "react"; import React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const { t } = useTranslation();
return ( return (
<View className="flex items-center justify-center h-full -mt-12"> <View className="flex items-center justify-center h-full -mt-12">
<Text>{t("live_tv.coming_soon")}</Text> <Text>Coming soon</Text>
</View> </View>
); );
} }

View File

@@ -1,26 +1,20 @@
import { AddToFavorites } from "@/components/AddToFavorites"; import { Text } from "@/components/common/Text";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp"; import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker"; import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react"; import React from "react";
import { useEffect, useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const { id: seriesId, seasonIndex } = params as {
id: string; id: string;
@@ -38,6 +32,7 @@ const page: React.FC = () => {
userId: user?.Id, userId: user?.Id,
itemId: seriesId, itemId: seriesId,
}), }),
enabled: !!seriesId && !!api,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
@@ -61,50 +56,6 @@ const page: React.FC = () => {
[item] [item]
); );
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res?.data.Items || [];
},
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id,
});
useEffect(() => {
navigation.setOptions({
headerRight: () =>
!isLoading &&
item &&
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"
/>
)}
/>
</View>
),
});
}, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null; if (!item || !backdropUrl) return null;
return ( return (
@@ -139,7 +90,10 @@ const page: React.FC = () => {
} }
> >
<View className="flex flex-col pt-4"> <View className="flex flex-col pt-4">
<SeriesHeader item={item} /> <View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<View className="mb-4"> <View className="mb-4">
<NextUp seriesId={seriesId} /> <NextUp seriesId={seriesId} />
</View> </View>

View File

@@ -1,114 +0,0 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { View } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useTranslation } from "react-i18next";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation();
const { data: item, isError } = useQuery({
queryKey: ["item", id],
queryFn: async () => {
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
userId: user?.Id,
});
return res.data;
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeOut = (callback: any) => {
setTimeout(() => {
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
const fadeIn = (callback: any) => {
setTimeout(() => {
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
useEffect(() => {
if (item) {
fadeOut(() => {});
} else {
fadeIn(() => {});
}
}, [item]);
if (isError)
return (
<View className="flex flex-col items-center justify-center h-screen w-screen">
<Text>{t("item_card.could_not_load_item")}</Text>
</View>
);
return (
<View className="flex flex-1 relative">
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className="bg-transparent rounded-lg mb-4 w-full"
></View>
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
<View className="flex flex-row space-x-1 mb-8">
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
</View>
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
</Animated.View>
{item && <ItemContent item={item} />}
</View>
);
};
export default Page;

View File

@@ -1,95 +0,0 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import {Image} from "expo-image";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {uniqBy} from "lodash";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
image: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
(
type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
) + `/${companyId}`,
params
)
},
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Image
id={companyId}
key={companyId}
className="bottom-1 w-1/2"
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit="contain"
style={{
aspectRatio: "4/3",
}}
/>
}
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
);
}

View File

@@ -1,87 +0,0 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {uniqBy} from "lodash";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const {genreId, name, type} = local as unknown as {
genreId: string,
name: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
genre: genreId
};
return jellyseerrApi?.discover(
type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params
)
},
enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Text
className="text-4xl font-bold text-center bottom-1"
style={{
...textShadowStyle.shadow,
shadowRadius: 10
}}>
{name}
</Text>
}
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
);
}

View File

@@ -1,360 +0,0 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as DropdownMenu from "zeego/dropdown-menu";
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";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {
data: details,
isFetching,
isLoading,
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
refetchInterval: 0,
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!);
},
});
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
jellyseerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => {
setIssueType(undefined);
setIssueMessage(undefined);
bottomSheetModalRef?.current?.close();
});
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}
if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body)
return
}
requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
[details]
)
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<ItemActions item={details} />
</TouchableOpacity>
),
});
}
}, [details]);
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
headerImage={
<View>
{result.backdropPath ? (
<Image
cachePolicy={"memory-disk"}
transition={300}
style={{
width: "100%",
height: "100%",
}}
source={{
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces"
),
}}
/>
) : (
<View
style={{
width: "100%",
height: "100%",
}}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
>
<Ionicons
name="image-outline"
size={24}
color="white"
style={{ opacity: 0.4 }}
/>
</View>
)}
</View>
}
>
<View className="flex flex-col">
<View className="space-y-4">
<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} />
<Text
uiTextView
selectable
className="font-bold text-2xl mb-1"
>
{mediaTitle}
</Text>
<Text className="opacity-50">{releaseYear}</Text>
</View>
<Image
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
cachePolicy={"memory-disk"}
transition={300}
source={{
uri: posterSrc,
}}
/>
</View>
<View className="mb-4">
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button loading={true} disabled={true} color="purple"></Button>
) : canRequest ? (
<Button color="purple" onPress={request}>
{t("jellyseerr.request_button")}
</Button>
) : (
<Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
color="transparent"
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white" />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
{t("jellyseerr.report_issue_button")}
</Button>
)}
<OverviewText text={result.overview} className="mt-4" />
</View>
{result.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)
}
/>
)}
<DetailFacts
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
details={details}
/>
<Cast details={details} />
</View>
</View>
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
isAnime={isAnime}
onRequested={() => {
advancedReqModalRef?.current?.close()
refetch()
}}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<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.whats_wrong")}
</Text>
</View>
<View className="flex flex-col space-y-2 items-start">
<View className="flex flex-col">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{t("jellyseerr.types")}</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode="always"
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className="mt-auto" onPress={submitIssue} color="purple">
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};
export default Page;

View File

@@ -1,110 +0,0 @@
import {
useLocalSearchParams,
useSegments,
} from "expo-router";
import React, { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Text } from "@/components/common/Text";
import { Image } from "expo-image";
import { OverviewText } from "@/components/OverviewText";
import {orderBy, uniqBy} from "lodash";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { useTranslation } from "react-i18next";
export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string };
const { data, isLoading, isFetching } = useQuery({
queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
}),
enabled: !!jellyseerrApi && !!personId,
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() =>
uniqBy(orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc"
), 'id'),
[data?.combinedCredits]
);
const backdrops = useMemo(
() => jellyseerrApi
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, data?.combinedCredits]
);
return (
<ParallaxSlideShow
data={castedRoles}
images={backdrops}
listHeader={t("jellyseerr.appearances")}
keyExtractor={(item) => item.id.toString()}
logo={
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className="rounded-full bottom-1"
source={{
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2"
),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
width: 125,
height: 125,
}}
/>
}
HeaderContent={() => (
<>
<Text className="font-bold text-2xl mb-1">
{data?.details?.name}
</Text>
<Text className="opacity-50">
{t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
}
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>
)}
MainContent={() => (
<OverviewText text={data?.details?.biography} className="mt-4" />
)}
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
/>
);
}

View File

@@ -1,8 +1,12 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router"; import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native"; import { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -12,7 +16,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
genreFilterAtom, genreFilterAtom,
@@ -32,7 +35,6 @@ import {
import { import {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
getFilterApi, getFilterApi,
@@ -41,7 +43,9 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next"; import { useOrientation } from "@/hooks/useOrientation";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -63,8 +67,6 @@ const Page = () => {
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) { if (sop) {
@@ -143,18 +145,6 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => { }): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null; if (!api || !library) return null;
let itemType: BaseItemKind | undefined;
// This fix makes sure to only return 1 type of items, if defined.
// This is because the underlying directory some times contains other types, and we don't want to show them.
if (library.CollectionType === "movies") {
itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
}
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user?.Id, userId: user?.Id,
parentId: libraryId, parentId: libraryId,
@@ -163,14 +153,12 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"], sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]], sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
// true is needed for merged versions recursive: false,
recursive: true,
imageTypeLimit: 1, imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"], fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)), years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined,
}); });
return response.data || null; return response.data || null;
@@ -301,7 +289,7 @@ const Page = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title={t("library.filters.genres")} title="Genres"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -328,7 +316,7 @@ const Page = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title={t("library.filters.years")} title="Years"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -353,7 +341,7 @@ const Page = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title={t("library.filters.tags")} title="Tags"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -371,7 +359,7 @@ const Page = () => {
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title={t("library.filters.sort_by")} title="Sort By"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -391,7 +379,7 @@ const Page = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title={t("library.filters.sort_order")} title="Sort Order"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -437,7 +425,7 @@ const Page = () => {
if (flatData.length === 0) if (flatData.length === 0)
return ( return (
<View className="h-full w-full flex justify-center items-center"> <View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text> <Text className="text-lg text-neutral-500">No items found</Text>
</View> </View>
); );
@@ -446,7 +434,7 @@ const Page = () => {
key={orientation} key={orientation}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text> <Text className="font-bold text-xl text-neutral-500">No results</Text>
</View> </View>
} }
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"

View File

@@ -4,12 +4,9 @@ import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
@@ -20,15 +17,11 @@ export default function IndexLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.library"), headerTitle: "Library",
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
!pluginSettings?.libraryOptions?.locked &&
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Ionicons <Ionicons
@@ -46,11 +39,11 @@ export default function IndexLayout() {
side={"bottom"} side={"bottom"}
sideOffset={10} sideOffset={10}
> >
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label> <DropdownMenu.Label>Display</DropdownMenu.Label>
<DropdownMenu.Group key="display-group"> <DropdownMenu.Group key="display-group">
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger"> <DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.display")} Display
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
@@ -73,7 +66,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-1"> <DropdownMenu.ItemTitle key="display-title-1">
{t("library.options.row")} Row
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
@@ -90,14 +83,14 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2"> <DropdownMenu.ItemTitle key="display-title-2">
{t("library.options.list")} List
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger"> <DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.image_style")} Image style
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
alignOffset={-10} alignOffset={-10}
@@ -120,7 +113,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title"> <DropdownMenu.ItemTitle key="poster-title">
{t("library.options.poster")} Poster
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
@@ -137,7 +130,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title"> <DropdownMenu.ItemTitle key="cover-title">
{t("library.options.cover")} Cover
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
@@ -161,7 +154,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title"> <DropdownMenu.ItemTitle key="show-titles-title">
{t("library.options.show_titles")} Show titles
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
@@ -178,7 +171,7 @@ export default function IndexLayout() {
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title"> <DropdownMenu.ItemTitle key="show-stats-title">
{t("library.options.show_stats")} Show stats
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.Group> </DropdownMenu.Group>

View File

@@ -10,10 +10,9 @@ import {
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function index() { export default function index() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -21,29 +20,23 @@ export default function index() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({ const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id], queryKey: ["user-views", user?.Id],
queryFn: async () => { queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({ if (!api || !user?.Id) {
userId: user?.Id, return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
}); });
return response.data.Items || null; return response.data.Items || null;
}, },
staleTime: 60, enabled: !!api && !!user?.Id,
staleTime: 60 * 1000 * 60,
}); });
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries]
);
useEffect(() => { useEffect(() => {
for (const item of data || []) { for (const item of data || []) {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
@@ -70,10 +63,10 @@ export default function index() {
</View> </View>
); );
if (!libraries) if (!data)
return ( return (
<View className="h-full w-full flex justify-center items-center"> <View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text> <Text className="text-lg text-neutral-500">No libraries found</Text>
</View> </View>
); );
@@ -88,7 +81,7 @@ export default function index() {
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
data={libraries} data={data}
renderItem={({ item }) => <LibraryItemCard library={item} />} renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""} keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => ItemSeparatorComponent={() =>

View File

@@ -1,13 +1,8 @@
import { import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
commonScreenOptions,
nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() { export default function SearchLayout() {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -15,10 +10,7 @@ export default function SearchLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.search"), headerTitle: "Search",
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
@@ -37,10 +29,6 @@ export default function SearchLayout() {
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />
<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> </Stack>
); );
} }

View File

@@ -1,17 +1,17 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage"; import { Loader } from "@/components/Loader";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { TAB_HEIGHT } from "@/constants/Values";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { import {
BaseItemDto, BaseItemDto,
BaseItemKind, BaseItemKind,
@@ -31,9 +31,6 @@ import React, {
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover";
const exampleSearches = [ const exampleSearches = [
"Lord of the rings", "Lord of the rings",
@@ -48,11 +45,8 @@ export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { q, prev } = params as { q: string; prev: Href<string> }; const { q, prev } = params as { q: string; prev: Href<string> };
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500); const [debouncedSearch] = useDebounce(search, 500);
@@ -61,7 +55,6 @@ export default function search() {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr();
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin"; return settings?.searchEngine || "Jellyfin";
@@ -79,45 +72,37 @@ export default function search() {
types: BaseItemKind[]; types: BaseItemKind[];
query: string; query: string;
}): Promise<BaseItemDto[]> => { }): Promise<BaseItemDto[]> => {
if (!api || !query) return []; if (!api) return [];
try { if (searchEngine === "Jellyfin") {
if (searchEngine === "Jellyfin") { const searchApi = await getSearchApi(api).getSearchHints({
const searchApi = await getSearchApi(api).getSearchHints({ searchTerm: query,
searchTerm: query, limit: 10,
limit: 10, includeItemTypes: types,
includeItemTypes: types, });
});
return (searchApi.data.SearchHints as BaseItemDto[]) || []; return searchApi.data.SearchHints as BaseItemDto[];
} else { } else {
if (!settings?.marlinServerUrl) return []; const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
query
)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const url = `${ const response1 = await axios.get(url);
settings.marlinServerUrl const ids = response1.data.ids;
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url); if (!ids || !ids.length) return [];
const ids = response1.data.ids; const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
if (!ids || !ids.length) return []; return response2.data.Items as BaseItemDto[];
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
}
} catch (error) {
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
} }
}, },
[api, searchEngine, settings] [api, settings]
); );
const navigation = useNavigation(); const navigation = useNavigation();
@@ -125,7 +110,7 @@ export default function search() {
if (Platform.OS === "ios") if (Platform.OS === "ios")
navigation.setOptions({ navigation.setOptions({
headerSearchBarOptions: { headerSearchBarOptions: {
placeholder: t("search.search"), placeholder: "Search...",
onChangeText: (e: any) => { onChangeText: (e: any) => {
router.setParams({ q: "" }); router.setParams({ q: "" });
setSearch(e.nativeEvent.text); setSearch(e.nativeEvent.text);
@@ -143,7 +128,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Movie"], types: ["Movie"],
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: debouncedSearch.length > 0,
}); });
const { data: series, isFetching: l2 } = useQuery({ const { data: series, isFetching: l2 } = useQuery({
@@ -153,7 +138,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Series"], types: ["Series"],
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: debouncedSearch.length > 0,
}); });
const { data: episodes, isFetching: l3 } = useQuery({ const { data: episodes, isFetching: l3 } = useQuery({
@@ -163,7 +148,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Episode"], types: ["Episode"],
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: debouncedSearch.length > 0,
}); });
const { data: collections, isFetching: l7 } = useQuery({ const { data: collections, isFetching: l7 } = useQuery({
@@ -173,7 +158,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["BoxSet"], types: ["BoxSet"],
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: debouncedSearch.length > 0,
}); });
const { data: actors, isFetching: l8 } = useQuery({ const { data: actors, isFetching: l8 } = useQuery({
@@ -183,22 +168,55 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Person"], types: ["Person"],
}), }),
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: debouncedSearch.length > 0,
});
const { data: artists, isFetching: l4 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: albums, isFetching: l5 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: songs, isFetching: l6 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
}); });
const noResults = useMemo(() => { const noResults = useMemo(() => {
return !( return !(
artists?.length ||
albums?.length ||
songs?.length ||
movies?.length || movies?.length ||
episodes?.length || episodes?.length ||
series?.length || series?.length ||
collections?.length || collections?.length ||
actors?.length actors?.length
); );
}, [episodes, movies, series, collections, actors]); }, [artists, episodes, albums, songs, movies, series, collections, actors]);
const loading = useMemo(() => { const loading = useMemo(() => {
return l1 || l2 || l3 || l7 || l8; return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l7, l8]); }, [l1, l2, l3, l4, l5, l6, l7, l8]);
return ( return (
<> <>
@@ -208,165 +226,236 @@ export default function search() {
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}} }}
> >
<View className="flex flex-col"> <View className="flex flex-col pt-2">
{Platform.OS === "android" && ( {Platform.OS === "android" && (
<View className="mb-4 px-4"> <View className="mb-4 px-4">
<Input <Input
autoCorrect={false} autoCorrect={false}
returnKeyType="done" returnKeyType="done"
keyboardType="web-search" keyboardType="web-search"
placeholder={t("search.search_here")} placeholder="Search here..."
value={search} value={search}
onChangeText={(text) => setSearch(text)} onChangeText={(text) => setSearch(text)}
/> />
</View> </View>
)} )}
{jellyseerrApi && ( {!!q && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2"> <View className="px-4 flex flex-col space-y-2">
<TouchableOpacity onPress={() => setSearchType("Library")}> <Text className="text-neutral-500 ">
<Tag Results for <Text className="text-purple-600">{q}</Text>
text={t("search.library")} </Text>
textClass="p-1"
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass="p-1"
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
</View> </View>
)} )}
<SearchItemWrapper
<View className="mt-2"> header="Movies"
<LoadingSkeleton isLoading={loading} /> ids={movies?.map((m) => m.Id!)}
</View> renderItem={(item) => (
<TouchableItemRouter
{searchType === "Library" ? ( key={item.Id}
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}> className="flex flex-col w-28 mr-2"
<SearchItemWrapper item={item}
header={t("search.movies")} >
ids={movies?.map((m) => m.Id!)} <MoviePoster item={item} key={item.Id} />
renderItem={(item: BaseItemDto) => ( <Text numberOfLines={2} className="mt-2">
<TouchableItemRouter {item.Name}
key={item.Id} </Text>
className="flex flex-col w-28 mr-2" <Text className="opacity-50 text-xs">
item={item} {item.ProductionYear}
> </Text>
<MoviePoster item={item} key={item.Id} /> </TouchableItemRouter>
<Text numberOfLines={2} className="mt-2"> )}
{item.Name} />
</Text> <SearchItemWrapper
<Text className="opacity-50 text-xs"> ids={series?.map((m) => m.Id!)}
{item.ProductionYear} header="Series"
</Text> renderItem={(item) => (
</TouchableItemRouter> <TouchableItemRouter
)} key={item.Id}
/> item={item}
<SearchItemWrapper className="flex flex-col w-28 mr-2"
ids={series?.map((m) => m.Id!)} >
header={t("search.series")} <SeriesPoster item={item} key={item.Id} />
renderItem={(item: BaseItemDto) => ( <Text numberOfLines={2} className="mt-2">
<TouchableItemRouter {item.Name}
key={item.Id} </Text>
item={item} <Text className="opacity-50 text-xs">
className="flex flex-col w-28 mr-2" {item.ProductionYear}
> </Text>
<SeriesPoster item={item} key={item.Id} /> </TouchableItemRouter>
<Text numberOfLines={2} className="mt-2"> )}
{item.Name} />
</Text> <SearchItemWrapper
<Text className="opacity-50 text-xs"> ids={episodes?.map((m) => m.Id!)}
{item.ProductionYear} header="Episodes"
</Text> renderItem={(item) => (
</TouchableItemRouter> <TouchableItemRouter
)} item={item}
/> key={item.Id}
<SearchItemWrapper className="flex flex-col w-44 mr-2"
ids={episodes?.map((m) => m.Id!)} >
header={t("search.episodes")} <ContinueWatchingPoster item={item} />
renderItem={(item: BaseItemDto) => ( <ItemCardText item={item} />
<TouchableItemRouter </TouchableItemRouter>
item={item} )}
key={item.Id} />
className="flex flex-col w-44 mr-2" <SearchItemWrapper
> ids={collections?.map((m) => m.Id!)}
<ContinueWatchingPoster item={item} /> header="Collections"
<ItemCardText item={item} /> renderItem={(item) => (
</TouchableItemRouter> <TouchableItemRouter
)} key={item.Id}
/> item={item}
<SearchItemWrapper className="flex flex-col w-28 mr-2"
ids={collections?.map((m) => m.Id!)} >
header={t("search.collections")} <MoviePoster item={item} key={item.Id} />
renderItem={(item: BaseItemDto) => ( <Text numberOfLines={2} className="mt-2">
<TouchableItemRouter {item.Name}
key={item.Id} </Text>
item={item} </TouchableItemRouter>
className="flex flex-col w-28 mr-2" )}
> />
<MoviePoster item={item} key={item.Id} /> <SearchItemWrapper
<Text numberOfLines={2} className="mt-2"> ids={actors?.map((m) => m.Id!)}
{item.Name} header="Actors"
</Text> renderItem={(item) => (
</TouchableItemRouter> <TouchableItemRouter
)} item={item}
/> key={item.Id}
<SearchItemWrapper className="flex flex-col w-28 mr-2"
ids={actors?.map((m) => m.Id!)} >
header={t("search.actors")} <MoviePoster item={item} />
renderItem={(item: BaseItemDto) => ( <ItemCardText item={item} />
<TouchableItemRouter </TouchableItemRouter>
item={item} )}
key={item.Id} />
className="flex flex-col w-28 mr-2" <SearchItemWrapper
> ids={artists?.map((m) => m.Id!)}
<MoviePoster item={item} /> header="Artists"
<ItemCardText item={item} /> renderItem={(item) => (
</TouchableItemRouter> <TouchableItemRouter
)} item={item}
/> key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
</View> </View>
) : ( ) : noResults && debouncedSearch.length > 0 ? (
<JellyserrIndexPage searchQuery={debouncedSearch} /> <View>
)} <Text className="text-center text-lg font-bold mt-4">
No results found for
{searchType === "Library" && ( </Text>
<> <Text className="text-xs text-purple-600 text-center">
{!loading && noResults && debouncedSearch.length > 0 ? ( "{debouncedSearch}"
<View> </Text>
<Text className="text-center text-lg font-bold mt-4"> </View>
{t("search.no_results_found_for")} ) : debouncedSearch.length === 0 ? (
</Text> <View className="mt-4 flex flex-col items-center space-y-2">
<Text className="text-xs text-purple-600 text-center"> {exampleSearches.map((e) => (
"{debouncedSearch}" <TouchableOpacity
</Text> onPress={() => setSearch(e)}
</View> key={e}
) : debouncedSearch.length === 0 ? ( className="mb-2"
<View className="mt-4 flex flex-col items-center space-y-2"> >
{exampleSearches.map((e) => ( <Text className="text-purple-600">{e}</Text>
<TouchableOpacity </TouchableOpacity>
onPress={() => setSearch(e)} ))}
key={e} </View>
className="mb-2" ) : null}
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</>
)}
</View> </View>
</ScrollView> </ScrollView>
</> </>
); );
} }
type Props = {
ids?: string[] | null;
renderItem: (item: BaseItemDto) => React.ReactNode;
header?: string;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data.map((item) => renderItem(item))}
</ScrollView>
</>
);
};

View File

@@ -1,139 +1,87 @@
import React, { useCallback, useRef } from "react"; import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings"; import { BlurView } from "expo-blur";
import { storage } from "@/utils/mmkv"; import * as NavigationBar from "expo-navigation-bar";
import type { import { Tabs } from "expo-router";
ParamListBase, import React, { useEffect } from "react";
TabNavigationState, import { Platform, StyleSheet } from "react-native";
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
NativeBottomTabNavigationEventMap
>(Navigator);
export default function TabLayout() { export default function TabLayout() {
const [settings] = useSettings(); useEffect(() => {
const { t } = useTranslation(); if (Platform.OS === "android") {
const router = useRouter(); NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
useFocusEffect( }
useCallback(() => { }, []);
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
const timer = setTimeout(() => {
router.push("/intro/page");
}, 1000);
return () => {
clearTimeout(timer);
};
}
}, [])
);
return ( return (
<> <Tabs
<SystemBars hidden={false} style="light" /> initialRouteName="home"
<NativeTabs screenOptions={{
sidebarAdaptable={false} tabBarActiveTintColor: Colors.tabIconSelected,
ignoresTopSafeArea headerShown: false,
barTintColor={Platform.OS === "android" ? "#121212" : undefined} tabBarStyle: {
tabBarActiveTintColor={Colors.primary} position: "absolute",
scrollEdgeAppearance="default" borderTopLeftRadius: 0,
> borderTopRightRadius: 0,
<NativeTabs.Screen redirect name="index" /> borderTopWidth: 0,
<NativeTabs.Screen paddingTop: 8,
name="(home)" paddingBottom: Platform.OS === "android" ? 8 : 26,
options={{ height: Platform.OS === "android" ? 58 : 74,
title: t("tabs.home"), },
tabBarIcon: tabBarBackground: () =>
Platform.OS == "android" Platform.OS === "ios" ? (
? ({ color, focused, size }) => <BlurView
require("@/assets/icons/house.fill.png") experimentalBlurMethod="dimezisBlurView"
: ({ focused }) => intensity={95}
focused style={{
? { sfSymbol: "house.fill" } ...StyleSheet.absoluteFillObject,
: { sfSymbol: "house" }, overflow: "hidden",
}} borderTopLeftRadius: 0,
/> borderTopRightRadius: 0,
<NativeTabs.Screen backgroundColor: "black",
name="(search)" }}
options={{ />
title: t("tabs.search"), ) : undefined,
tabBarIcon: }}
Platform.OS == "android" >
? ({ color, focused, size }) => <Tabs.Screen redirect name="index" />
require("@/assets/icons/magnifyingglass.png") <Tabs.Screen
: ({ focused }) => name="(home)"
focused options={{
? { sfSymbol: "magnifyingglass" } headerShown: false,
: { sfSymbol: "magnifyingglass" }, title: "Home",
}} tabBarIcon: ({ color, focused }) => (
/> <TabBarIcon
<NativeTabs.Screen name={focused ? "home" : "home-outline"}
name="(favorites)" color={color}
options={{ />
title: t("tabs.favorites"), ),
tabBarIcon: }}
Platform.OS == "android" />
? ({ color, focused, size }) => <Tabs.Screen
focused name="(search)"
? require("@/assets/icons/heart.fill.png") options={{
: require("@/assets/icons/heart.png") headerShown: false,
: ({ focused }) => title: "Search",
focused tabBarIcon: ({ color, focused }) => (
? { sfSymbol: "heart.fill" } <TabBarIcon name={focused ? "search" : "search"} color={color} />
: { sfSymbol: "heart" }, ),
}} }}
/> />
<NativeTabs.Screen <Tabs.Screen
name="(libraries)" name="(libraries)"
options={{ options={{
title: t("tabs.library"), headerShown: false,
tabBarIcon: title: "Library",
Platform.OS == "android" tabBarIcon: ({ color, focused }) => (
? ({ color, focused, size }) => <TabBarIcon
require("@/assets/icons/server.rack.png") name={focused ? "apps" : "apps-outline"}
: ({ focused }) => color={color}
focused />
? { sfSymbol: "rectangle.stack.fill" } ),
: { sfSymbol: "rectangle.stack" }, }}
}} />
/> </Tabs>
<NativeTabs.Screen
name="(custom-links)"
options={{
title: t("tabs.custom_links"),
// @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}}
/>
</NativeTabs>
</>
); );
} }

308
app/(auth)/play-music.tsx Normal file
View File

@@ -0,0 +1,308 @@
import { Text } from "@/components/common/Text";
import AlbumCover from "@/components/posters/AlbumCover";
import { Controls } from "@/components/video-player/Controls";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import { debounce } from "lodash";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Dimensions, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
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 progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
async (ticks: number) => {
console.log("togglePlay");
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
const play = useCallback(() => {
console.log("play");
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
console.log("play");
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
console.log("stop");
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(progress.value),
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
if (!playSettings?.item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item.Id,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
},
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
return (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: playUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
imageUri: poster,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api, poster]);
return videoSource;
}

View File

@@ -0,0 +1,180 @@
import { Controls } from "@/components/video-player/Controls";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import * as Haptics from "expo-haptics";
import * as NavigationBar from "expo-navigation-bar";
import { useFocusEffect } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const { playSettings, playUrl } = usePlaySettings();
const api = useAtomValue(apiAtom);
const videoRef = useRef<VideoRef | null>(null);
const videoSource = useVideoSource(playSettings, api, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
} else {
videoRef.current?.resume();
}
}, [isPlaying]);
const play = useCallback(() => {
setIsPlaying(true);
videoRef.current?.resume();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaying(false);
videoRef.current?.pause();
}, [videoRef]);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
const onProgress = useCallback(async (data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
}, []);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
return (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = 0;
return {
uri: playUrl,
isNetwork: false,
startPosition,
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api]);
return videoSource;
}

340
app/(auth)/play-video.tsx Normal file
View File

@@ -0,0 +1,340 @@
import { Controls } from "@/components/video-player/Controls";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Dimensions, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrackType,
VideoRef,
} from "react-native-video";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
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 progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(progress.value),
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
if (!playSettings?.item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item.Id,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
},
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
const selectedSubtitleTrack = useMemo(() => {
const a = playSettings?.mediaSource?.MediaStreams?.find(
(s) => s.Index === playSettings.subtitleIndex
);
console.log(a);
return a;
}, [playSettings]);
const [hlsSubTracks, setHlsSubTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const selectedTextTrack = useMemo(() => {
for (let st of hlsSubTracks) {
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
return {
type: SelectedTrackType.TITLE,
value: selectedSubtitleTrack?.DisplayTitle ?? "",
};
}
}
return undefined;
}, [hlsSubTracks]);
return (
<View
style={{
flex: 1,
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: screenDimensions.width,
height: screenDimensions.height,
zIndex: 0,
}}
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
console.log("onTextTracks ~", data);
setHlsSubTracks(data.textTracks as any);
}}
selectedTextTrack={selectedTextTrack}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: playUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
imageUri: poster,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api, poster]);
return videoSource;
}

View File

@@ -1,31 +0,0 @@
import { Stack } from "expo-router";
import React from "react";
import { SystemBars } from "react-native-edge-to-edge";
export default function Layout() {
return (
<>
<SystemBars hidden />
<Stack>
<Stack.Screen
name="direct-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);
}

View File

@@ -1,528 +0,0 @@
import { BITRATES } from "@/components/BitrateSelector";
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 {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
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 { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } 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 { 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";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
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 progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
}, []);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
offline: offlineStr,
} = useGlobalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
offline: string;
}>();
const [settings] = useSettings();
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 {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline) {
const item = await 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 {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item)
return {
mediaSource: data.mediaSource,
url,
sessionId: undefined,
};
}
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 null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
});
const togglePlay = useCallback(async () => {
if (!api) return;
lightHapticFeedback();
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);
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!,
});
revalidateProgressCache();
}, [api, item, mediaSourceId, stream]);
const stop = useCallback(() => {
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
if (!stream) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
}, [api, item, mediaSourceId, stream]);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
progress.value = 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,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
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();
}
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio"
) || [];
const chosenAudioTrack = allAudio.find((audio) => 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}`,
};
}
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
}
const insets = useSafeAreaInsets();
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
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",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView
ref={videoRef}
source={{
uri: stream.url,
autoplay: true,
isNetwork: true,
startPosition,
externalTrack,
initOptions,
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadStart={() => {}}
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")
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
</View>
{videoRef.current && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc
/>
)}
</View>
);
}
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

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

View File

@@ -1,10 +1,13 @@
import { Link, Stack } from "expo-router"; import { Link, Stack, usePathname } from "expo-router";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { useEffect } from "react";
export default function NotFoundScreen() { export default function NotFoundScreen() {
const pathname = usePathname();
return ( return (
<> <>
<Stack.Screen options={{ title: "Oops!" }} /> <Stack.Screen options={{ title: "Oops!" }} />

View File

@@ -1,19 +1,15 @@
import "@/augmentations";
import { Text } from "@/components/common/Text";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStoraage,
JellyfinProvider, JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation"; import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings"; import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
@@ -23,6 +19,7 @@ import {
completeHandler, completeHandler,
download, download,
} from "@kesha-antonov/react-native-background-downloader"; } from "@kesha-antonov/react-native-background-downloader";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch"; import * as BackgroundFetch from "expo-background-fetch";
@@ -34,15 +31,12 @@ import * as Notifications from "expo-notifications";
import { router, Stack } from "expo-router"; import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import * as TaskManager from "expo-task-manager"; import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Appearance, AppState, TouchableOpacity } from "react-native"; import { AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { I18nextProvider, useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { getLocales } from "expo-localization";
import "react-native-reanimated"; import "react-native-reanimated";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
@@ -92,7 +86,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const now = Date.now(); const now = Date.now();
const settingsData = storage.getString("settings"); const settingsData = await AsyncStorage.getItem("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
@@ -102,13 +96,19 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
if (!settings?.autoDownload || !url) if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage(); const token = await getTokenFromStoraage();
const deviceId = getOrSetDeviceId(); const deviceId = await getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory; const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
console.log({
token,
url,
deviceId,
});
const jobs = await getAllJobsByDeviceId({ const jobs = await getAllJobsByDeviceId({
deviceId, deviceId,
authHeader: token, authHeader: token,
@@ -120,6 +120,14 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
for (let job of jobs) { for (let job of jobs) {
if (job.status === "completed") { if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id; const downloadUrl = url + "download/" + job.id;
console.log({
token,
deviceId,
baseDirectory,
url,
downloadUrl,
});
const tasks = await checkForExistingDownloads(); const tasks = await checkForExistingDownloads();
if (tasks.find((task) => task.id === job.id)) { if (tasks.find((task) => task.id === job.id)) {
@@ -129,7 +137,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
download({ download({
id: job.id, id: job.id,
url: downloadUrl, url: url + "download/" + job.id,
destination: `${baseDirectory}${job.item.Id}.mp4`, destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: { headers: {
Authorization: token, Authorization: token,
@@ -183,7 +191,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const checkAndRequestPermissions = async () => { const checkAndRequestPermissions = async () => {
try { try {
const hasAskedBefore = storage.getString( const hasAskedBefore = await AsyncStorage.getItem(
"hasAskedForNotificationPermission" "hasAskedForNotificationPermission"
); );
@@ -198,7 +206,7 @@ const checkAndRequestPermissions = async () => {
console.log("Notification permissions denied."); console.log("Notification permissions denied.");
} }
storage.set("hasAskedForNotificationPermission", "true"); await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
} else { } else {
console.log("Already asked for notification permissions before."); console.log("Already asked for notification permissions before.");
} }
@@ -223,33 +231,17 @@ export default function RootLayout() {
} }
}, [loaded]); }, [loaded]);
Appearance.setColorScheme("dark");
if (!loaded) { if (!loaded) {
return null; return null;
} }
return ( return (
<JotaiProvider> <JotaiProvider>
<I18nextProvider i18n={i18n}> <Layout />
<Layout />
</I18nextProvider>
</JotaiProvider> </JotaiProvider>
); );
} }
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
});
function Layout() { function Layout() {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom); const [orientation, setOrientation] = useAtom(orientationAtom);
@@ -257,7 +249,19 @@ function Layout() {
useKeepAwake(); useKeepAwake();
useNotificationObserver(); useNotificationObserver();
const { i18n } = useTranslation(); const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
})
);
useEffect(() => { useEffect(() => {
checkAndRequestPermissions(); checkAndRequestPermissions();
@@ -272,12 +276,6 @@ function Layout() {
); );
}, [settings]); }, [settings]);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
);
}, [settings?.preferedLanguage, i18n]);
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
useEffect(() => { useEffect(() => {
@@ -321,63 +319,80 @@ function Layout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClientRef.current}>
<ActionSheetProvider> <ActionSheetProvider>
<JobQueueProvider> <JobQueueProvider>
<JellyfinProvider> <JellyfinProvider>
<PlaySettingsProvider> <PlaySettingsProvider>
<LogProvider> <DownloadProvider>
<WebSocketProvider> <BottomSheetModalProvider>
<DownloadProvider> <StatusBar style="light" backgroundColor="#000" />
<BottomSheetModalProvider> <ThemeProvider value={DarkTheme}>
<SystemBars style="light" hidden={false} /> <Stack initialRouteName="/home">
<ThemeProvider value={DarkTheme}> <Stack.Screen
<Stack initialRouteName="/home"> name="(auth)/(tabs)"
<Stack.Screen options={{
name="(auth)/(tabs)" headerShown: false,
options={{ title: "",
headerShown: false, }}
title: "", />
header: () => null, <Stack.Screen
}} name="(auth)/play-video"
/> options={{
<Stack.Screen headerShown: false,
name="(auth)/player" autoHideHomeIndicator: true,
options={{ title: "",
headerShown: false, animation: "fade",
title: "", }}
header: () => null, />
}} <Stack.Screen
/> name="logs"
<Stack.Screen options={{
name="login" presentation: "modal",
options={{ title: "Logs",
headerShown: true, }}
title: "", />
headerTransparent: true, <Stack.Screen
}} name="(auth)/play-offline-video"
/> options={{
<Stack.Screen name="+not-found" /> headerShown: false,
</Stack> autoHideHomeIndicator: true,
<Toaster title: "",
duration={4000} animation: "fade",
toastOptions={{ }}
style: { />
backgroundColor: "#262626", <Stack.Screen
borderColor: "#363639", name="(auth)/play-music"
borderWidth: 1, options={{
}, headerShown: false,
titleStyle: { autoHideHomeIndicator: true,
color: "white", title: "",
}, animation: "fade",
}} }}
closeButton />
/> <Stack.Screen
</ThemeProvider> name="login"
</BottomSheetModalProvider> options={{ headerShown: false, title: "Login" }}
</DownloadProvider> />
</WebSocketProvider> <Stack.Screen name="+not-found" />
</LogProvider> </Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</PlaySettingsProvider> </PlaySettingsProvider>
</JellyfinProvider> </JellyfinProvider>
</JobQueueProvider> </JobQueueProvider>
@@ -387,9 +402,9 @@ function Layout() {
); );
} }
function saveDownloadedItemInfo(item: BaseItemDto) { async function saveDownloadedItemInfo(item: BaseItemDto) {
try { try {
const downloadedItems = storage.getString("downloadedItems"); const downloadedItems = await AsyncStorage.getItem("downloadedItems");
let items: BaseItemDto[] = downloadedItems let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems) ? JSON.parse(downloadedItems)
: []; : [];
@@ -401,7 +416,7 @@ function saveDownloadedItemInfo(item: BaseItemDto) {
items.push(item); items.push(item);
} }
storage.set("downloadedItems", JSON.stringify(items)); await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
} catch (error) { } catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error); writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error); console.error("Failed to save downloaded item information:", error);

View File

@@ -1,34 +1,34 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
SafeAreaView, SafeAreaView,
TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { t } from 'i18next';
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),});
const Login: React.FC = () => { const CredentialsSchema = z.object({
username: z.string().min(1, "Username is required"),
});
const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const router = useRouter();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { const {
@@ -39,6 +39,7 @@ const CredentialsSchema = z.object({
const [serverURL, setServerURL] = useState<string>(_apiUrl); const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>(""); const [serverName, setServerName] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
username: string; username: string;
password: string; password: string;
@@ -66,25 +67,6 @@ const CredentialsSchema = z.object({
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
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>
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => { const handleLogin = async () => {
@@ -92,13 +74,23 @@ const CredentialsSchema = z.object({
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (result.success) { if (result.success) {
await login(credentials.username, credentials.password); try {
await login(credentials.username, credentials.password);
} catch (loginError) {
if (loginError instanceof Error) {
setError(loginError.message);
} else {
setError("An unexpected error occurred during login");
}
}
} else {
setError("Invalid credentials format");
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); setError(error.message);
} else { } else {
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured")); setError("An unexpected error occurred");
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -122,28 +114,78 @@ const CredentialsSchema = z.object({
* - Sets loadingServerCheck state to true at the beginning and false at the end. * - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console. * - Logs errors and timeout information to the console.
*/ */
const checkUrl = useCallback(async (url: string) => { async function checkUrl(url: string) {
url = url.endsWith("/") ? url.slice(0, -1) : url;
setLoadingServerCheck(true); setLoadingServerCheck(true);
writeToLog("INFO", `Checking URL: ${url}`);
const timeout = 5000; // 5 seconds timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
const response = await fetch(`${url}/System/Info/Public`, { // Try HTTPS first
mode: "cors", const httpsUrl = `https://${url}/System/Info/Public`;
}); try {
const response = await fetch(httpsUrl, {
if (response.ok) { mode: "cors",
const data = (await response.json()) as PublicSystemInfo; signal: controller.signal,
});
setServerName(data.ServerName || ""); if (response.ok) {
return url; const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return `https://${url}`;
} else {
writeToLog(
"WARN",
`HTTPS connection failed with status: ${response.status}`
);
}
} catch (e) {
writeToLog("WARN", "HTTPS connection failed - trying HTTP", e);
} }
// If HTTPS didn't work, try HTTP
const httpUrl = `http://${url}/System/Info/Public`;
try {
const response = await fetch(httpUrl, {
mode: "cors",
signal: controller.signal,
});
writeToLog("INFO", `HTTP response status: ${response.status}`);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return `http://${url}`;
} else {
writeToLog(
"WARN",
`HTTP connection failed with status: ${response.status}`
);
}
} catch (e) {
writeToLog("ERROR", "HTTP connection failed", e);
}
// If neither worked, return undefined
writeToLog(
"ERROR",
`Failed to connect to ${url} using both HTTPS and HTTP`
);
return undefined; return undefined;
} catch { } catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
writeToLog("ERROR", `Request to ${url} timed out`, error);
} else {
writeToLog("ERROR", `Unexpected error checking ${url}`, error);
}
return undefined; return undefined;
} finally { } finally {
clearTimeout(timeoutId);
setLoadingServerCheck(false); setLoadingServerCheck(false);
} }
}, []); }
/** /**
* Handles the connection attempt to a Jellyfin server. * Handles the connection attempt to a Jellyfin server.
@@ -161,168 +203,185 @@ const CredentialsSchema = z.object({
* - Sets the server address using `setServer` if the connection is successful. * - Sets the server address using `setServer` if the connection is successful.
* *
*/ */
const handleConnect = useCallback(async (url: string) => { const handleConnect = async (url: string) => {
url = url.trim().replace(/\/$/, ""); url = url.trim();
const result = await checkUrl(url);
const result = await checkUrl(
url.startsWith("http") ? new URL(url).host : url
);
if (result === undefined) { if (result === undefined) {
Alert.alert( Alert.alert(
t("login.connection_failed"), "Connection failed",
t("login.could_not_connect_to_server") "Could not connect to the server. Please check the URL and your network connection."
); );
return; return;
} }
setServer({ address: url }); setServer({ address: result });
}, []); };
const handleQuickConnect = async () => { const handleQuickConnect = async () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [ Alert.alert("Quick Connect", `Enter code ${code} to login`, [
{ {
text: t("login.got_it"), text: "Got It",
}, },
]); ]);
} }
} catch (error) { } catch (error) {
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect")); Alert.alert("Error", "Failed to initiate Quick Connect");
} }
}; };
return ( if (api?.basePath) {
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}> return (
<KeyboardAvoidingView <SafeAreaView style={{ flex: 1 }}>
behavior={Platform.OS === "ios" ? "padding" : "height"} <KeyboardAvoidingView
> behavior={Platform.OS === "ios" ? "padding" : "height"}
{api?.basePath ? ( style={{ flex: 1, height: "100%" }}
<> >
<View className="flex flex-col h-full relative items-center justify-center"> <View className="flex flex-col w-full h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full"> <View className="absolute top-4 right-4">
<View className="flex flex-col space-y-2"> <Ionicons
<Text className="text-2xl font-bold -mb-2"> name="file-tray-full-outline"
<> size={22}
{serverName ? ( color="white"
<> onPress={() => {
{t("login.login_to_title") + " "} router.push("/logs");
<Text className="text-purple-600">{serverName}</Text> }}
</> />
) : t("login.login_title")} </View>
</> <View className="px-4 -mt-20">
<View className="mb-4">
<Text className="text-3xl font-bold mb-1">
{serverName || "Streamyfin"}
</Text> </Text>
<Text className="text-xs text-neutral-400"> <View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
<Text className="">URL</Text>
<Text numberOfLines={1} className="shrink">
{api.basePath} {api.basePath}
</Text> </Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
<View className="flex flex-row items-center justify-between">
<Button
onPress={handleLogin}
loading={loading}
className="flex-1 mr-2"
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
>
<MaterialCommunityIcons
name="cellphone-lock"
size={24}
color="white"
/>
</TouchableOpacity>
</View>
</View> </View>
<Button
color="black"
onPress={() => {
removeServer();
}}
justify="between"
iconLeft={
<Ionicons
name="arrow-back-outline"
size={18}
color={"white"}
/>
}
>
Change server
</Button>
</View> </View>
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View> <View className="flex flex-col space-y-2">
</View> <Text className="text-2xl font-bold">Log in</Text>
</>
) : (
<>
<View className="flex flex-col h-full items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input <Input
aria-label="Server URL" placeholder="Username"
placeholder={t("server.server_url_placeholder")} onChangeText={(text) =>
onChangeText={setServerURL} setCredentials({ ...credentials, username: text })
value={serverURL} }
keyboardType="url" value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done" returnKeyType="done"
autoCapitalize="none" autoCapitalize="none"
textContentType="URL" textContentType="username"
clearButtonMode="while-editing"
maxLength={500} maxLength={500}
/> />
<Button
loading={loadingServerCheck} <Input
disabled={loadingServerCheck} className="mb-2"
onPress={async () => await handleConnect(serverURL)} placeholder="Password"
className="w-full grow" onChangeText={(text) =>
> setCredentials({ ...credentials, password: text })
{t("server.connect_button")} }
</Button> value={credentials.password}
<JellyfinServerDiscovery secureTextEntry
onServerSelect={(server) => { keyboardType="default"
setServerURL(server.address); returnKeyType="done"
if (server.serverName) { autoCapitalize="none"
setServerName(server.serverName); textContentType="password"
} clearButtonMode="while-editing"
handleConnect(server.address); maxLength={500}
}}
/>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
}}
/> />
</View> </View>
<Text className="text-red-600 mb-2">{error}</Text>
</View> </View>
</>
)} <View className="absolute bottom-0 left-0 w-full px-4 mb-2">
<Button
color="black"
onPress={handleQuickConnect}
className="w-full mb-2"
>
Use Quick Connect
</Button>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col h-full relative items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
</View>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
</View>
</View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
); );

58
app/logs.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { Text } from "@/components/common/Text";
import { readFromLog } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Logs: React.FC = () => {
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => (await readFromLog()).reverse(),
refetchOnReconnect: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
});
const insets = useSafeAreaInsets();
return (
<ScrollView
className="flex-1 p-4"
contentContainerStyle={{ gap: 10, paddingBottom: insets.top }}
>
<View className="flex flex-col">
{logs?.map((log, index) => (
<View key={index} className="border-b-neutral-800 border py-3">
<View className="flex flex-row justify-between items-center mb-2">
<Text
className={`
text-xs
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text className="text-xs text-neutral-500">
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text uiTextView selectable className="text-xs mb-1">
{log.message}
</Text>
{log.data && (
<Text uiTextView selectable className="text-xs">
{log.data}
</Text>
)}
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">No logs available</Text>
)}
</View>
</ScrollView>
);
};
export default Logs;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,65 +0,0 @@
<svg
type="certified"
viewBox="0 0 80 80"
preserveAspectRatio="xMidYMid"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g transform="translate(2.29, 0)">
<path
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
id="Fill-2"
fill="#00912D"
></path>
<mask id="mask-2" fill="white">
<polygon
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
></polygon>
</mask>
<path
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
fill="#FFD700"
mask="url(#mask-2)"
></path>
<path
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
fill="#FA6E0F"
mask="url(#mask-2)"
></path>
<path
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
fill="#0AC855"
mask="url(#mask-2)"
></path>
<path
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
fill="#0B4902"
></path>
<g transform="translate(0, 20.57)">
<mask id="mask-4" fill="white">
<polygon
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
></polygon>
</mask>
<path
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
fill="#FA3200"
mask="url(#mask-4)"
></path>
<path
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
fill="#0AC855"
mask="url(#mask-4)"
></path>
</g>
<path
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
fill="#00912D"
></path>
<path
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
fill="#0B4902"
></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,46 +0,0 @@
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
import { AxiosRequestConfig, AxiosResponse } from "axios";
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
declare module "@jellyfin/sdk" {
interface Api {
get<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
post<T, D = any>(
url: string,
data: D,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
}
}
Api.prototype.get = function <T, D = any>(
url: string,
config: AxiosRequestConfig<D> = {}
): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
...(config ?? {}),
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};
Api.prototype.post = function <T, D = any>(
url: string,
data: D,
config: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
...(config || {}),
data,
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};
Api.prototype.getStreamyfinPluginConfig = function (): Promise<
AxiosResponse<StreamyfinPluginConfig>
> {
return this.get<StreamyfinPluginConfig>("/Streamyfin/config");
};

View File

@@ -1,4 +0,0 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

View File

@@ -1,22 +0,0 @@
import {MMKV} from "react-native-mmkv";
declare module "react-native-mmkv" {
interface MMKV {
get<T>(key: string): T | undefined
setAny(key: string, value: any | undefined): void
}
}
MMKV.prototype.get = function <T> (key: string): T | undefined {
const serializedItem = this.getString(key);
return serializedItem ? JSON.parse(serializedItem) : undefined;
}
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
if (value === undefined) {
this.delete(key)
}
else {
this.set(key, JSON.stringify(value));
}
}

View File

@@ -1,35 +0,0 @@
declare global {
interface Number {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
Number.prototype.bytesToReadable = function (decimals: number = 2) {
const bytes = this.valueOf();
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
Number.prototype.secondsToMilliseconds = function () {
return this.valueOf() * 1000;
};
Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

View File

@@ -1,16 +0,0 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this
.replaceAll("_", " ")
.replace(
/\w\S*/g,
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
);
}
export {};

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,114 +0,0 @@
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";
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"] });
},
});
return (
<View {...props}>
<RoundButton
size="large"
icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={() => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/>
</View>
);
};

View File

@@ -3,12 +3,11 @@ import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source: MediaSourceInfo;
onChange: (value: number) => void; onChange: (value: number) => void;
selected?: number | undefined; selected?: number | null;
} }
export const AudioTrackSelector: React.FC<Props> = ({ export const AudioTrackSelector: React.FC<Props> = ({
@@ -18,7 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source.MediaStreams?.filter((x) => x.Type === "Audio"),
[source] [source]
); );
@@ -27,8 +26,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected] [audioStreams, selected]
); );
const { t } = useTranslation();
return ( return (
<View <View
className="flex shrink" className="flex shrink"
@@ -39,7 +36,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.audio")}</Text> <Text className="opacity-50 mb-1 text-xs">Audio</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}> <Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle} {selectedAudioSteam?.DisplayTitle}

View File

@@ -2,11 +2,11 @@ import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
value: number | undefined; value: number | undefined;
height?: number;
}; };
export const BITRATES: Bitrate[] = [ export const BITRATES: Bitrate[] = [
@@ -27,18 +27,17 @@ export const BITRATES: Bitrate[] = [
{ {
key: "2 Mb/s", key: "2 Mb/s",
value: 2000000, value: 2000000,
}, height: 720,
{
key: "1 Mb/s",
value: 1000000,
}, },
{ {
key: "500 Kb/s", key: "500 Kb/s",
value: 500000, value: 500000,
height: 480,
}, },
{ {
key: "250 Kb/s", key: "250 Kb/s",
value: 250000, value: 250000,
height: 480,
}, },
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity)); ].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
@@ -64,8 +63,6 @@ export const BitrateSelector: React.FC<Props> = ({
); );
}, []); }, []);
const { t } = useTranslation();
return ( return (
<View <View
className="flex shrink" className="flex shrink"
@@ -77,7 +74,7 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.quality")}</Text> <Text className="opacity-50 mb-1 text-xs">Quality</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}> <Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key} {BITRATES.find((b) => b.value === selected?.value)?.key}

View File

@@ -1,10 +1,9 @@
import { useHaptic } from "@/hooks/useHaptic"; import * as Haptics from "expo-haptics";
import React, { PropsWithChildren, ReactNode, useMemo } from "react"; import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
export interface ButtonProps interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void; onPress?: () => void;
className?: string; className?: string;
textClassName?: string; textClassName?: string;
@@ -37,35 +36,31 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "red": case "red":
return "bg-red-600"; return "bg-red-600";
case "black": case "black":
return "bg-neutral-900"; return "bg-neutral-900 border border-neutral-800";
case "transparent": case "transparent":
return "bg-transparent"; return "bg-transparent";
} }
}, [color]); }, [color]);
const lightHapticFeedback = useHaptic("light");
return ( return (
<TouchableOpacity <TouchableOpacity
className={` className={`
p-3 rounded-xl items-center justify-center p-3 rounded-xl items-center justify-center
${(loading || disabled) && "opacity-50"} ${loading || (disabled && "opacity-50")}
${colorClasses} ${colorClasses}
${className} ${className}
`} `}
onPress={() => { onPress={() => {
if (!loading && !disabled && onPress) { if (!loading && !disabled && onPress) {
onPress(); onPress();
lightHapticFeedback(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} }
}} }}
disabled={disabled || loading} disabled={disabled || loading}
{...props} {...props}
> >
{loading ? ( {loading ? (
<View className="p-0.5"> <Loader />
<Loader />
</View>
) : ( ) : (
<View <View
className={` className={`

View File

@@ -1,16 +1,14 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import React, { useCallback, useEffect } from "react"; import React, { useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native"; import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton,
CastContext, CastContext,
useCastDevice, useCastDevice,
useDevices, useDevices,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
} from "react-native-google-cast"; } from "react-native-google-cast";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
width?: number; width?: number;
@@ -34,7 +32,6 @@ export const Chromecast: React.FC<Props> = ({
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!discoveryManager) { if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return; return;
} }
@@ -42,45 +39,49 @@ export const Chromecast: React.FC<Props> = ({
})(); })();
}, [client, devices, castDevice, sessionManager, discoveryManager]); }, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
if (background === "transparent") if (background === "transparent")
return ( return (
<RoundButton <TouchableOpacity
size="large"
className="mr-2"
background={false}
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog(); else CastContext.showCastDialog();
}} }}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props} {...props}
> >
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} /> <Feather name="cast" size={22} color={"white"} />
</RoundButton> </TouchableOpacity>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
); );
return ( return (
<RoundButton <TouchableOpacity
size="large"
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog(); else CastContext.showCastDialog();
}} }}
{...props} {...props}
> >
<AndroidCastButton /> <BlurView
<Feather name="cast" size={22} color={"white"} /> intensity={100}
</RoundButton> className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
</TouchableOpacity>
); );
}; };

View File

@@ -1,30 +1,27 @@
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
import React from "react"; import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
item: BaseItemDto; item: BaseItemDto;
useEpisodePoster?: boolean; useEpisodePoster?: boolean;
size?: "small" | "normal"; size?: "small" | "normal";
showPlayButton?: boolean;
}; };
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item, item,
useEpisodePoster = false, useEpisodePoster = false,
size = "normal", size = "normal",
showPlayButton = false,
}) => { }) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
/** /**
* Get horizontal poster for movie and episode, with failover to primary. * Get horrizontal poster for movie and episode, with failover to primary.
*/ */
const url = useMemo(() => { const url = useMemo(() => {
if (!api) return; if (!api) return;
@@ -49,11 +46,6 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
else else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
} }
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]); }, [item]);
const progress = useMemo(() => { const progress = useMemo(() => {
@@ -81,23 +73,16 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"} ${size === "small" ? "w-32" : "w-44"}
`} `}
> >
<View className="w-full h-full flex items-center justify-center"> <Image
<Image key={item.Id}
key={item.Id} id={item.Id}
id={item.Id} source={{
source={{ uri: url,
uri: url, }}
}} cachePolicy={"memory-disk"}
cachePolicy={"memory-disk"} contentFit="cover"
contentFit="cover" className="w-full h-full"
className="w-full h-full" />
/>
{showPlayButton && (
<View className="absolute inset-0 flex items-center justify-center">
<Ionicons name="play-circle" size={40} color="white" />
</View>
)}
</View>
{!progress && <WatchedIndicator item={item} />} {!progress && <WatchedIndicator item={item} />}
{progress > 0 && ( {progress > 0 && (
<> <>

View File

@@ -2,11 +2,10 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { queueActions, queueAtom } from "@/utils/atoms/queue";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import ios from "@/utils/profiles/ios";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import native from "@/utils/profiles/native";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import old from "@/utils/profiles/old";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -18,50 +17,36 @@ import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Href, router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native"; import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next"; import { toast } from "sonner-native";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; item: BaseItemDto;
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
title?: string;
subtitle?: string;
size?: "default" | "large";
} }
export const DownloadItems: React.FC<DownloadProps> = ({ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
title = "Download",
subtitle = "",
size = "default",
...props
}) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { startRemuxing } = useRemuxHlsToMp4(item);
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState< const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null MediaSourceInfo | undefined
>(undefined); >(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1); const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const [selectedSubtitleStream, setSelectedSubtitleStream] =
@@ -71,15 +56,27 @@ export const DownloadItems: React.FC<DownloadProps> = ({
value: undefined, value: undefined,
}); });
const userCanDownload = useMemo( useFocusEffect(
() => user?.Policy?.EnableContentDownloading, useCallback(() => {
[user] if (!settings) return;
); const { bitrate, mediaSource, audioIndex, subtitleIndex } =
const usingOptimizedServer = useMemo( getDefaultPlaySettings(item, settings);
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings] // 4. Set states
setSelectedMediaSource(mediaSource);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
); );
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
/**
* Bottom sheet
*/
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => { const handlePresentModalPress = useCallback(() => {
@@ -92,161 +89,114 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
}, []); }, []);
const itemIds = useMemo(() => items.map((i) => i.Id), [items]); /**
* Start download
*/
const initiateDownload = useCallback(async () => {
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
const itemsNotDownloaded = useMemo( let deviceProfile: any = iosFmp4;
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
);
const allItemsDownloaded = useMemo(() => { if (settings?.deviceProfile === "Native") {
if (items.length === 0) return false; deviceProfile = native;
return itemsNotDownloaded.length === 0; } else if (settings?.deviceProfile === "Old") {
}, [items, itemsNotDownloaded]); deviceProfile = old;
const itemsProcesses = useMemo( }
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
);
const progress = useMemo(() => { const response = await api.axiosInstance.post(
if (itemIds.length == 1) `${api.basePath}/Items/${item.Id}/PlaybackInfo`,
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); {
return ( DeviceProfile: deviceProfile,
((itemIds.length - UserId: user.Id,
queue.filter((q) => itemIds.includes(q.item.Id)).length) / MaxStreamingBitrate: maxBitrate.value,
itemIds.length) * StartTimeTicks: 0,
100 EnableTranscoding: maxBitrate.value ? true : undefined,
); AutoOpenLiveStream: true,
}, [queue, itemsProcesses, itemIds]); AllowVideoStreamCopy: maxBitrate.value ? false : true,
MediaSourceId: selectedMediaSource?.Id,
const itemsQueued = useMemo(() => { AudioStreamIndex: selectedAudioStream,
return ( SubtitleStreamIndex: selectedSubtitleStream,
itemsNotDownloaded.length > 0 && },
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id)) {
); headers: {
}, [queue, itemsNotDownloaded]); Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
const navigateToDownloads = () => router.push("/downloads"); },
const onDownloadedPress = () => {
const firstItem = items?.[0];
router.push(
firstItem.Type !== "Episode"
? "/downloads"
: ({
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href)
);
};
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
} }
closeModal(); );
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded); let url: string | undefined = undefined;
else { let fileExtension: string | undefined | null = "mp4";
queueActions.enqueue(
queue, const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
setQueue, (source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
...itemsNotDownloaded.map((item) => ({ );
id: item.Id!,
execute: async () => await initiateDownload(item), if (!mediaSource) {
item, throw new Error("No media source");
})) }
);
if (mediaSource.SupportsDirectPlay) {
if (item.MediaType === "Video") {
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: user.Id,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
url = `${api.basePath}/Audio/${
item.Id
}/universal?${searchParams.toString()}`;
} }
} else if (mediaSource.TranscodingUrl) {
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
fileExtension = mediaSource.TranscodingContainer;
}
if (!url) throw new Error("No url");
if (!fileExtension) throw new Error("No file extension");
if (settings?.downloadMethod === "optimized") {
return await startBackgroundDownload(url, item, fileExtension);
} else { } else {
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files")); return await startRemuxing(url);
} }
}, [ }, [
queue, api,
setQueue, item,
itemsNotDownloaded, startBackgroundDownload,
usingOptimizedServer, user?.Id,
userCanDownload,
maxBitrate,
selectedMediaSource, selectedMediaSource,
selectedAudioStream, selectedAudioStream,
selectedSubtitleStream, selectedSubtitleStream,
maxBitrate,
settings?.downloadMethod,
]); ]);
const initiateDownload = useCallback( /**
async (...items: BaseItemDto[]) => { * Check if item is downloaded
if ( */
!api || const { downloadedFiles } = useDownload();
!user?.Id ||
items.some((p) => !p.Id) ||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
let mediaSource = selectedMediaSource;
let audioIndex: number | undefined = selectedAudioStream;
let subtitleIndex: number | undefined = selectedSubtitleStream;
for (const item of items) { const isDownloaded = useMemo(() => {
if (itemsNotDownloaded.length > 1) { if (!downloadedFiles) return false;
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
}
const res = await getStreamUrl({ return downloadedFiles.some((file) => file.Id === item.Id);
api, }, [downloadedFiles, item.Id]);
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
if (!res) {
Alert.alert(
t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin")
);
continue;
}
const { mediaSource: source, url } = res;
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
}
}
},
[
api,
user?.Id,
itemsNotDownloaded,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]
);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -258,61 +208,59 @@ export const DownloadItems: React.FC<DownloadProps> = ({
), ),
[] []
); );
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
setSelectedMediaSource(mediaSource ?? undefined); const process = useMemo(() => {
setSelectedAudioStream(audioIndex ?? 0); if (!processes) return null;
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings])
);
const renderButtonContent = () => { return processes.find((process) => process?.item?.Id === item.Id);
if (processes && itemsProcesses.length > 0) { }, [processes, item.Id]);
return progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
);
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
}
};
const onButtonPress = () => {
if (processes && itemsProcesses.length > 0) {
navigateToDownloads();
} else if (itemsQueued) {
navigateToDownloads();
} else if (allItemsDownloaded) {
onDownloadedPress();
} else {
handlePresentModalPress();
}
};
return ( return (
<View {...props}> <View
<RoundButton size={size} onPress={onButtonPress}> className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{renderButtonContent()} {...props}
</RoundButton> >
{process && process?.item.Id === item.Id ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
{process.progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
)}
</TouchableOpacity>
) : queue.some((i) => i.id === item.Id) ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : isDownloaded ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
)}
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
@@ -327,57 +275,68 @@ export const DownloadItems: React.FC<DownloadProps> = ({
> >
<BottomSheetView> <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2"> <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View> <Text className="font-bold text-2xl text-neutral-10">
<Text className="font-bold text-2xl text-neutral-100"> Download options
{title} </Text>
</Text>
<Text className="text-neutral-300">
{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"> <View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector <BitrateSelector
inverted inverted
onChange={setMaxBitrate} onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate} selected={maxBitrate}
/> />
{itemsNotDownloaded.length === 1 && ( <MediaSourceSelector
<> item={item}
<MediaSourceSelector onChange={setSelectedMediaSource}
item={items[0]} selected={selectedMediaSource}
onChange={setSelectedMediaSource} />
selected={selectedMediaSource} {selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/> />
{selectedMediaSource && ( <SubtitleTrackSelector
<View className="flex flex-col space-y-2"> source={selectedMediaSource}
<AudioTrackSelector onChange={setSelectedSubtitleStream}
source={selectedMediaSource} selected={selectedSubtitleStream}
onChange={setSelectedAudioStream} />
selected={selectedAudioStream} </View>
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</>
)} )}
</View> </View>
<Button <Button
className="mt-auto" className="mt-auto"
onPress={acceptDownloadOptions} onPress={() => {
if (userCanDownload === true) {
if (!item.Id) {
throw new Error("No item id");
}
closeModal();
if (settings?.downloadMethod === "remux") {
queueActions.enqueue(queue, setQueue, {
id: item.Id,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
initiateDownload();
}
} else {
toast.error("You are not allowed to download files.");
}
}}
color="purple" color="purple"
> >
{t("item_card.download.download_button")} Download
</Button> </Button>
<View className="opacity-70 text-center w-full flex items-center"> <View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs"> {settings?.downloadMethod === "optimized" ? (
{usingOptimizedServer <Text className="text-xs">Using optimized server</Text>
? t("item_card.download.using_optimized_server") ) : (
: t("item_card.download.using_default_method")} <Text className="text-xs">Using default method</Text>
</Text> )}
</View> </View>
</View> </View>
</BottomSheetView> </BottomSheetView>
@@ -385,25 +344,3 @@ export const DownloadItems: React.FC<DownloadProps> = ({
</View> </View>
); );
}; };
export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
return (
<DownloadItems
size={size}
title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
);
};

View File

@@ -1,44 +1,25 @@
// GenreTags.tsx // GenreTags.tsx
import React from "react"; import React from "react";
import {StyleProp, TextStyle, View, ViewProps} from "react-native"; import { View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface TagProps { interface GenreTagsProps {
tags?: string[]; genres?: string[];
textClass?: ViewProps["className"]
} }
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({ export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
text, if (!genres || genres.length === 0) return null;
textClass,
textStyle,
...props
}) => {
return (
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
<Text className={textClass} style={textStyle}>{text}</Text>
</View>
);
};
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
if (!tags || tags.length === 0) return null;
return ( return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}> <View className="flex flex-row flex-wrap mt-2">
{tags.map((tag, idx) => ( {genres.map((genre) => (
<View key={idx}> <View
<Tag key={idx} textClass={textClass} text={tag}/> key={genre}
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
>
<Text className="text-xs">{genre}</Text>
</View> </View>
))} ))}
</View> </View>
); );
}; };
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
return (
<View className="mt-2">
<Tags tags={genres}/>
</View>
);
};

View File

@@ -13,13 +13,12 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
<View className="mt-2 flex flex-col"> <View className="mt-2 flex flex-col">
{item.Type === "Episode" ? ( {item.Type === "Episode" ? (
<> <>
<Text numberOfLines={1} className=""> <Text numberOfLines={2} className="">
{item.Name} {item.SeriesName}
</Text> </Text>
<Text numberOfLines={1} className="text-xs opacity-50"> <Text numberOfLines={1} className="text-xs opacity-50">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
{" - "} {item.Name}
{item.SeriesName}
</Text> </Text>
</> </>
) : ( ) : (

View File

@@ -1,6 +1,6 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem"; import { DownloadItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton"; import { PlayButton } from "@/components/PlayButton";
@@ -11,75 +11,126 @@ import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries"; import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useNavigation } from "expo-router"; import { useFocusEffect, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { View } from "react-native"; import { Alert, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast"; import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { AddToFavorites } from "./AddToFavorites";
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => { ({ item }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const [settings] = useSettings(); const [settings] = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets();
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true); const [loadingLogo, setLoadingLogo] = useState(true);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
setPlaySettings({
item,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
if (!mediaSource) {
Alert.alert("Error", "No media source found for this item.");
navigation.goBack();
}
}, [item, settings])
);
const selectedMediaSource = useMemo(() => {
return playSettings?.mediaSource || undefined;
}, [playSettings?.mediaSource]);
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
setPlaySettings((prev) => ({
...prev,
mediaSource,
}));
};
const selectedAudioStream = useMemo(() => {
return playSettings?.audioIndex;
}, [playSettings?.audioIndex]);
const setSelectedAudioStream = (audioIndex: number) => {
setPlaySettings((prev) => ({
...prev,
audioIndex,
}));
};
const selectedSubtitleStream = useMemo(() => {
return playSettings?.subtitleIndex;
}, [playSettings?.subtitleIndex]);
const setSelectedSubtitleStream = (subtitleIndex: number) => {
setPlaySettings((prev) => ({
...prev,
subtitleIndex,
}));
};
const maxBitrate = useMemo(() => {
return playSettings?.bitrate;
}, [playSettings?.bitrate]);
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
console.log("setMaxBitrate", bitrate);
setPlaySettings((prev) => ({
...prev,
bitrate,
}));
};
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const [headerHeight, setHeaderHeight] = useState(350); const [headerHeight, setHeaderHeight] = useState(350);
const [selectedOptions, setSelectedOptions] = useState< useImageColors({ item });
SelectedOptions | undefined
>(undefined);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings);
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
@@ -89,9 +140,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<Chromecast background="blur" width={22} height={22} /> <Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" /> <DownloadItem item={item} />
<PlayedStatus item={item} /> <PlayedStatus item={item} />
<AddToFavorites item={item} type="item" />
</View> </View>
)} )}
</View> </View>
@@ -100,9 +150,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}, [item]); }, [item]);
useEffect(() => { useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) // If landscape
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
setHeaderHeight(230); setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500); return;
}
if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350); else setHeaderHeight(350);
}, [item.Type, orientation]); }, [item.Type, orientation]);
@@ -112,37 +166,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return Boolean(logoUrl && loadingLogo); return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]); }, [loadingLogo, logoUrl]);
const [isTranscoding, setIsTranscoding] = useState(false); const insets = useSafeAreaInsets();
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 ( return (
<View <View
@@ -195,86 +219,51 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<View className="flex flex-row items-center justify-start w-full h-16"> <View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector <BitrateSelector
className="mr-1" className="mr-1"
onChange={(val) => onChange={(val) => setMaxBitrate(val)}
setSelectedOptions( selected={maxBitrate}
(prev) => prev && { ...prev, bitrate: val }
)
}
selected={selectedOptions.bitrate}
/> />
<MediaSourceSelector <MediaSourceSelector
className="mr-1" className="mr-1"
item={item} item={item}
onChange={(val) => onChange={setSelectedMediaSource}
setSelectedOptions( selected={selectedMediaSource}
(prev) =>
prev && {
...prev,
mediaSource: val,
}
)
}
selected={selectedOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
}
);
}}
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
}
)
}
selected={selectedOptions.subtitleIndex}
/> />
{selectedMediaSource && (
<>
<AudioTrackSelector
className="mr-1"
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</>
)}
</View> </View>
)} )}
<PlayButton <PlayButton item={item} url={playUrl} className="grow" />
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} /> <SeasonEpisodesCarousel item={item} loading={loading} />
)} )}
<ItemTechnicalDetails source={selectedOptions.mediaSource} /> <OverviewText text={item.Overview} className="px-4 my-4" />
<OverviewText text={item.Overview} className="px-4 mb-4" />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<CastAndCrew item={item} className="mb-4" loading={loading} /> <CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && ( {item.People && item.People.length > 0 && (
<View className="mb-4"> <View className="mb-4">
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person) => (
<MoreMoviesWithActor <MoreMoviesWithActor
currentItem={item} currentItem={item}
key={idx} key={person.Id}
actorId={person.Id!} actorId={person.Id!}
className="mb-4" className="mb-4"
/> />
@@ -282,9 +271,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item.Id} /> <SimilarItems itemId={item.Id} />
</> </>
)} )}
<View className="h-16"></View>
</View> </View>
</ParallaxScrollView> </ParallaxScrollView>
</View> </View>

View File

@@ -1,11 +1,10 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View, ViewProps } from "react-native"; import { View, ViewProps } from "react-native";
import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader"; import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings"; import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader"; import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { ItemActions } from "./series/SeriesActions"; import { GenreTags } from "./GenreTags";
import React from "react";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -28,10 +27,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
return ( return (
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<View className="flex flex-row items-center justify-between"> <Ratings item={item} className="mb-2" />
<Ratings item={item} className="mb-2" />
<ItemActions item={item} />
</View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<> <>
<EpisodeTitleHeader item={item} /> <EpisodeTitleHeader item={item} />

View File

@@ -1,240 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import {
MediaSourceInfo,
type MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "./Badge";
import { Text } from "./common/Text";
import {
BottomSheetModal,
BottomSheetBackdropProps,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
import { useTranslation } from "react-i18next";
interface Props {
source?: MediaSourceInfo;
}
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
return (
<View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={["80%"]}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<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>
<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>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio"
) || []
}
/>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle"
) || []
}
/>
</View>
</View>
</BottomSheetScrollView>
</BottomSheetModal>
</View>
);
};
const SubtitleStreamInfo = ({
subtitleStreams,
}: {
subtitleStreams: MediaStream[];
}) => {
return (
<View className="flex flex-col">
{subtitleStreams.map((stream, index) => (
<View key={stream.Index} className="flex flex-col">
<Text className="text-xs mb-3 text-neutral-400">
{stream.DisplayTitle}
</Text>
<View className="flex flex-row flex-wrap gap-2">
<Badge
variant="gray"
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
}
text={stream.Language}
/>
<Badge
variant="gray"
text={stream.Codec}
iconLeft={
<Ionicons name="layers-outline" size={16} color="white" />
}
/>
</View>
</View>
))}
</View>
);
};
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return (
<View className="flex flex-col">
{audioStreams.map((audioStreams, index) => (
<View key={index} className="flex flex-col">
<Text className="mb-3 text-neutral-400 text-xs">
{audioStreams.DisplayTitle}
</Text>
<View className="flex-row flex-wrap gap-2">
<Badge
variant="gray"
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
}
text={audioStreams.Language}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons
name="musical-notes-outline"
size={16}
color="white"
/>
}
text={audioStreams.Codec}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
text={audioStreams.ChannelLayout}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
}
text={formatBitrate(audioStreams.BitRate)}
/>
</View>
</View>
))}
</View>
);
};
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
if (!source) return null;
const videoStream = useMemo(() => {
return source.MediaStreams?.find(
(stream) => stream.Type === "Video"
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return (
<View className="flex-row flex-wrap gap-2">
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={formatFileSize(source.Size)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="color-palette-outline" size={16} color="white" />
}
text={videoStream.VideoRange}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="code-working-outline" size={16} color="white" />
}
text={videoStream.Codec}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
}
text={formatBitrate(videoStream.BitRate)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
/>
</View>
);
};
const formatFileSize = (bytes?: number | null) => {
if (!bytes) return "N/A";
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
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];
};

View File

@@ -1,46 +0,0 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import { Button } from "./Button";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Props {
onServerSelect?: (server: { address: string; serverName?: string }) => void;
}
const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
const { t } = useTranslation();
return (
<View className="mt-2">
<Button onPress={startDiscovery} color="black">
<Text className="text-white text-center">
{isSearching ? t("server.searching") : t("server.search_for_local_servers")}
</Text>
</Button>
{servers.length ? (
<ListGroup title={t("server.servers")} className="mt-4">
{servers.map((server) => (
<ListItem
key={server.address}
onPress={() =>
onServerSelect?.({
address: server.address,
serverName: server.serverName,
})
}
title={server.address}
showArrow
/>
))}
</ListGroup>
) : null}
</View>
);
};
export default JellyfinServerDiscovery;

35
components/ListItem.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { PropsWithChildren, ReactNode } from "react";
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
...props
}) => {
return (
<View
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text uiTextView selectable className="text-xs">
{subTitle}
</Text>
)}
</View>
{iconAfter}
</View>
);
};

View File

@@ -1,12 +1,13 @@
import { tc } from "@/utils/textTools";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next"; import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -25,30 +26,21 @@ export const MediaSourceSelector: React.FC<Props> = ({
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video" (x) => x.Type === "Video"
)?.DisplayTitle || "", )?.DisplayTitle || "",
[item, selected] [item.MediaSources, selected]
); );
const { t } = useTranslation(); useEffect(() => {
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
const commonPrefix = useMemo(() => { onChange(item.MediaSources[0]);
const mediaSources = item.MediaSources || [];
if (!mediaSources.length) return "";
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
const char = mediaSources[0].Name![i];
if (mediaSources.every((source) => source.Name![i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
} }
return commonPrefix; }, [item.MediaSources, selected]);
}, [item.MediaSources]);
const name = (name?: string | null) => { const name = (name?: string | null) => {
return name?.replace(commonPrefix, "").toLowerCase(); if (name && name.length > 40)
return (
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
);
return name;
}; };
return ( return (
@@ -61,7 +53,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.video")}</Text> <Text className="opacity-50 mb-1 text-xs">Video</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -85,7 +77,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
}} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{`${name(source.Name)}`} {`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
))} ))}

View File

@@ -11,7 +11,6 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
actorId: string; actorId: string;
@@ -25,7 +24,6 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: actor } = useQuery({ const { data: actor } = useQuery({
queryKey: ["actor", actorId], queryKey: ["actor", actorId],
@@ -78,7 +76,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className="text-lg font-bold mb-2 px-4"> <Text className="text-lg font-bold mb-2 px-4">
{t("item_card.more_with", {name: actor?.Name})} More with {actor?.Name}
</Text> </Text>
<HorizontalScroll <HorizontalScroll
data={items} data={items}

View File

@@ -2,7 +2,6 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
text?: string | null; text?: string | null;
@@ -15,13 +14,12 @@ export const OverviewText: React.FC<Props> = ({
...props ...props
}) => { }) => {
const [limit, setLimit] = useState(characterLimit); const [limit, setLimit] = useState(characterLimit);
const { t } = useTranslation();
if (!text) return null; if (!text) return null;
return ( return (
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text> <Text className="text-lg font-bold mb-2">Overview</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
setLimit((prev) => setLimit((prev) =>
@@ -33,7 +31,7 @@ export const OverviewText: React.FC<Props> = ({
<Text>{tc(text, limit)}</Text> <Text>{tc(text, limit)}</Text>
{text.length > characterLimit && ( {text.length > characterLimit && (
<Text className="text-purple-600 mt-1"> <Text className="text-purple-600 mt-1">
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")} {limit === characterLimit ? "Show more" : "Show less"}
</Text> </Text>
)} )}
</View> </View>

View File

@@ -1,6 +1,6 @@
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react"; import { type PropsWithChildren, type ReactElement } from "react";
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native"; import { View, ViewProps } from "react-native";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedRef, useAnimatedRef,
@@ -13,7 +13,6 @@ interface Props extends ViewProps {
logo?: ReactElement; logo?: ReactElement;
episodePoster?: ReactElement; episodePoster?: ReactElement;
headerHeight?: number; headerHeight?: number;
onEndReached?: (() => void) | null | undefined;
} }
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
@@ -22,7 +21,6 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
episodePoster, episodePoster,
headerHeight = 400, headerHeight = 400,
logo, logo,
onEndReached,
...props ...props
}: Props) => { }: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>(); const scrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -49,11 +47,6 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
}; };
}); });
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
}
return ( return (
<View className="flex-1" {...props}> <View className="flex-1" {...props}>
<Animated.ScrollView <Animated.ScrollView
@@ -62,10 +55,6 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
}} }}
ref={scrollRef} ref={scrollRef}
scrollEventThrottle={16} scrollEventThrottle={16}
onScroll={e => {
if (isCloseToBottom(e.nativeEvent))
onEndReached?.()
}}
> >
{logo && ( {logo && (
<View <View

View File

@@ -1,20 +1,15 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router"; import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai"; import { useEffect, useMemo } from "react";
import { useCallback, useEffect } from "react"; import { Linking, TouchableOpacity, View } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -30,72 +25,61 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import { Text } from "./common/Text";
import { chromecastProfile } from "@/utils/profiles/chromecast"; import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item?: BaseItemDto | null;
selectedOptions: SelectedOptions; url?: string | null;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15; const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
item,
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom); const [api] = useAtom(apiAtom);
const user = useAtomValue(userAtom);
const router = useRouter(); const router = useRouter();
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom); const endColor = useSharedValue(memoizedColor);
const startColor = useSharedValue(colorAtom); const startColor = useSharedValue(memoizedColor);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings(); const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const directStream = useMemo(() => {
(q: string, bitrateValue: number | undefined) => { return !url?.includes("m3u8");
if (!bitrateValue) { }, [url]);
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
},
[router]
);
const onPress = useCallback(async () => { const onPress = async () => {
if (!item) return; if (!url || !item) {
console.warn(
lightHapticFeedback(); "No URL or item provided to PlayButton",
url?.slice(0, 100),
const queryParams = new URLSearchParams({ item?.Id
itemId: item.Id!, );
audioIndex: selectedOptions.audioIndex?.toString() ?? "", return;
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", }
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
});
const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value); const vlcLink = "vlc://" + url;
if (vlcLink && settings?.openInVLC) {
Linking.openURL(vlcLink);
return;
}
router.push("/play-video");
return; return;
} }
@@ -114,36 +98,20 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
else { else {
// Get a new URL with the Chromecast device profile: // If we're opening a currently playing item, don't restart the media.
const data = await getStreamUrl({ // Instead just open controls.
api, if (isOpeningCurrentlyPlayingMedia) {
item, CastContext.showExpandedControls();
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return; return;
} }
client client
.loadMedia({ .loadMedia({
mediaInfo: { mediaInfo: {
contentUrl: data?.url, contentUrl: url,
contentType: "video/mp4", contentType: "video/mp4",
metadata: metadata:
item.Type === "Episode" item.Type === "Episode"
@@ -209,38 +177,28 @@ export const PlayButton: React.FC<Props> = ({
}); });
break; break;
case 1: case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value); router.push("/play-video");
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
} }
} }
); );
}, [ };
item,
client,
settings,
api,
user,
router,
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0; if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = item.UserData; const userData = memoizedItem.UserData;
if (userData && userData.PlaybackPositionTicks) { if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH MIN_PLAYBACK_WIDTH
) )
: 0; : 0;
} }
return 0; return 0;
}, [item]); }, [memoizedItem]);
useAnimatedReaction( useAnimatedReaction(
() => derivedTargetWidth.value, () => derivedTargetWidth.value,
@@ -256,7 +214,7 @@ export const PlayButton: React.FC<Props> = ({
); );
useAnimatedReaction( useAnimatedReaction(
() => colorAtom, () => memoizedColor,
(newColor) => { (newColor) => {
endColor.value = newColor; endColor.value = newColor;
colorChangeProgress.value = 0; colorChangeProgress.value = 0;
@@ -265,19 +223,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[colorAtom] [memoizedColor]
); );
useEffect(() => { useEffect(() => {
const timeout_2 = setTimeout(() => { const timeout_2 = setTimeout(() => {
startColor.value = colorAtom; startColor.value = memoizedColor;
startWidth.value = targetWidth.value; startWidth.value = targetWidth.value;
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
return () => { return () => {
clearTimeout(timeout_2); clearTimeout(timeout_2);
}; };
}, [colorAtom, item]); }, [memoizedColor, memoizedItem]);
/** /**
* ANIMATED STYLES * ANIMATED STYLES
@@ -320,11 +278,10 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<View> <View>
<TouchableOpacity <TouchableOpacity
disabled={!item}
accessibilityLabel="Play button" accessibilityLabel="Play button"
accessibilityHint="Tap to play the media" accessibilityHint="Tap to play the media"
onPress={onPress} onPress={onPress}
className={`relative`} className="relative"
{...props} {...props}
> >
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden"> <View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
@@ -361,7 +318,6 @@ export const PlayButton: React.FC<Props> = ({
{client && ( {client && (
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} /> <Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text> </Animated.Text>
)} )}
{!client && settings?.openInVLC && ( {!client && settings?.openInVLC && (
@@ -376,7 +332,7 @@ export const PlayButton: React.FC<Props> = ({
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center"> <View className="mt-2 flex flex-row items-center">
<Ionicons <Ionicons
name="information-circle" name="information-circle"
size={12} size={12}
@@ -386,7 +342,7 @@ export const PlayButton: React.FC<Props> = ({
<Text className="text-neutral-500 ml-1"> <Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"} {directStream ? "Direct stream" : "Transcoded stream"}
</Text> </Text>
</View> */} </View>
</View> </View>
); );
}; };

View File

@@ -1,15 +1,22 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { View, ViewProps } from "react-native"; import { TouchableOpacity, View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
} }
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => { export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidateQueries = () => { const invalidateQueries = () => {
@@ -34,21 +41,52 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["seasons"], queryKey: ["seasons"],
}); });
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["home"], queryKey: ["home"],
}); });
}; };
const markAsPlayedStatus = useMarkAsPlayed(item);
return ( return (
<View {...props}> <View
<RoundButton className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
fillColor={item.UserData?.Played ? "primary" : undefined} {...props}
icon={item.UserData?.Played ? "checkmark" : "checkmark"} >
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)} {item.UserData?.Played ? (
size="large" <TouchableOpacity
/> onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsNotPlayed({
api: api,
itemId: item?.Id,
userId: user?.Id,
});
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={24} color="white" />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsPlayed({
api: api,
item: item,
userId: user?.Id,
});
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
</View>
</TouchableOpacity>
)}
</View> </View>
); );
}; };

View File

@@ -1,51 +0,0 @@
import React, { useMemo } from "react";
import { View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Server {
address: string;
}
interface PreviousServersListProps {
onServerSelect: (server: Server) => void;
}
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onServerSelect,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as Server[];
}, [_previousServers]);
const { t } = useTranslation();
if (!previousServers.length) return null;
return (
<View>
<ListGroup title={t("server.previous_servers")} className="mt-4">
{previousServers.map((s) => (
<ListItem
key={s.address}
onPress={() => onServerSelect(s)}
title={s.address}
showArrow
/>
))}
<ListItem
onPress={() => {
setPreviousServers("[]");
}}
title={t("server.clear_button")}
textColor="red"
/>
</ListGroup>
</View>
);
};

View File

@@ -3,10 +3,6 @@ import { View, ViewProps } from "react-native";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
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";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -21,7 +17,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
)} )}
{item.CommunityRating && ( {item.CommunityRating && (
<Badge <Badge
text={item.CommunityRating.toFixed(1)} text={item.CommunityRating}
variant="gray" variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />} iconLeft={<Ionicons name="star" size={14} color="gold" />}
/> />
@@ -32,11 +28,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
variant="gray" variant="gray"
iconLeft={ iconLeft={
<Image <Image
source={ source={require("@/assets/images/rotten-tomatoes.png")}
item.CriticRating < 60
? require("@/assets/images/rotten-tomatoes.png")
: require("@/assets/images/not-rotten-tomatoes.svg")
}
style={{ style={{
width: 14, width: 14,
height: 14, height: 14,
@@ -48,86 +40,3 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
</View> </View>
); );
}; };
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
result,
}) => {
const { jellyseerrApi } = useJellyseerr();
const { data, isLoading } = useQuery({
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id);
},
staleTime: (5).minutesToMilliseconds(),
retry: false,
enabled: !!jellyseerrApi,
});
return (
(isLoading ||
!!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && (
<View className="flex flex-row flex-wrap space-x-1">
{data?.criticsRating && !!data?.criticsScore && (
<Badge
text={`${data.criticsScore}%`}
variant="gray"
iconLeft={
<Image
className="mr-1"
source={
data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
{data?.audienceRating && !!data?.audienceScore && (
<Badge
text={`${data.audienceScore}%`}
variant="gray"
iconLeft={
<Image
className="mr-1"
source={
data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
{!!result.voteCount && (
<Badge
text={`${Math.round(result.voteAverage * 10)}%`}
variant="gray"
iconLeft={
<Image
className="mr-1"
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
</View>
)
);
};

View File

@@ -1,115 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { PropsWithChildren } from "react";
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
onPress?: () => void;
icon?: keyof typeof Ionicons.glyphMap;
background?: boolean;
size?: "default" | "large";
fillColor?: "primary";
hapticFeedback?: boolean;
}
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
background = true,
icon,
onPress,
children,
size = "default",
fillColor,
hapticFeedback = true,
...props
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (hapticFeedback) {
lightHapticFeedback();
}
onPress?.();
};
if (fillColor)
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
if (background === false)
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-neutral-800/80"
}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
return (
<TouchableOpacity onPress={handlePress} {...props}>
<BlurView
intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</BlurView>
</TouchableOpacity>
);
};

View File

@@ -12,7 +12,6 @@ import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll"; import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter"; import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps { interface SimilarItemsProps extends ViewProps {
itemId?: string | null; itemId?: string | null;
@@ -24,7 +23,6 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({ const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
queryKey: ["similarItems", itemId], queryKey: ["similarItems", itemId],
@@ -49,12 +47,12 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text> <Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
<HorizontalScroll <HorizontalScroll
data={movies} data={movies}
loading={isLoading} loading={isLoading}
height={247} height={247}
noItemsText={t("item_card.no_similar_items_found")} noItemsText="No similar items found"
renderItem={(item: BaseItemDto, idx: number) => ( renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter <TouchableItemRouter
key={idx} key={idx}

View File

@@ -1,35 +1,26 @@
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source: MediaSourceInfo;
onChange: (value: number) => void; onChange: (value: number) => void;
selected?: number | undefined; selected?: number | null;
isTranscoding?: boolean;
} }
export const SubtitleTrackSelector: React.FC<Props> = ({ export const SubtitleTrackSelector: React.FC<Props> = ({
source, source,
onChange, onChange,
selected, selected,
isTranscoding,
...props ...props
}) => { }) => {
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []); () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
if (isTranscoding && Platform.OS === "ios") { );
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo( const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected), () => subtitleStreams.find((x) => x.Index === selected),
@@ -38,8 +29,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (subtitleStreams.length === 0) return null; if (subtitleStreams.length === 0) return null;
const { t } = useTranslation();
return ( return (
<View <View
className="flex col shrink justify-start place-self-start items-start" className="flex col shrink justify-start place-self-start items-start"
@@ -51,12 +40,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col " {...props}> <View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.subtitles")}</Text> <Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className=" "> <Text className=" ">
{selectedSubtitleSteam {selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7) ? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")} : "None"}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@@ -1,108 +0,0 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import {TouchableOpacity, View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> {
data: T[]
disabled?: boolean
placeholderText?: string,
keyExtractor: (item: T) => string
titleExtractor: (item: T) => string | undefined
title: string | ReactNode,
label: string,
onSelected: (...item: T[]) => void
multi?: boolean
}
const Dropdown = <T extends unknown>({
data,
disabled,
placeholderText,
keyExtractor,
titleExtractor,
title,
label,
onSelected,
multi = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const [selected, setSelected] = useState<T[]>();
useEffect(() => {
if (selected !== undefined) {
onSelected(...selected)
}
}, [selected]);
return (
<DisabledSetting
disabled={disabled === true}
showText={false}
{...props}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{typeof title === 'string' ? (
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{title}
</Text>
<TouchableOpacity
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
<>
{title}
</>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => (
multi ? (
<DropdownMenu.CheckboxItem
value={selected?.some(s => keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
key={keyExtractor(item)}
onValueChange={(next, previous) =>
setSelected((p) => {
const prev = p || []
if (next == 'on') {
return [...prev, item]
}
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
})
}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
: (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DisabledSetting>
)
};
export default Dropdown;

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