mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-25 13:33:13 +00:00
Compare commits
2 Commits
fix/test
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad751fd2c8 | ||
|
|
42e66b39cc |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -4,7 +4,9 @@ title: "[Bug]: "
|
|||||||
labels:
|
labels:
|
||||||
- ["❌ bug"]
|
- ["❌ bug"]
|
||||||
projects:
|
projects:
|
||||||
- ["streamyfin/3"]
|
- ["fredrikburmester/5"]
|
||||||
|
assignees:
|
||||||
|
- fredrikburmester
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -43,7 +45,7 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
- 0.24.0
|
- 0.23.0
|
||||||
- 0.22.0
|
- 0.22.0
|
||||||
- 0.21.0
|
- 0.21.0
|
||||||
- older
|
- older
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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**
|
||||||
|
|||||||
49
.github/workflows/build-ios.yaml
vendored
49
.github/workflows/build-ios.yaml
vendored
@@ -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
|
|
||||||
@@ -8,12 +8,12 @@ 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=150 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.
|
||||||
- 🔊 **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.
|
||||||
|
|||||||
7
app.json
7
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.24.0",
|
"version": "0.23.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 50,
|
"versionCode": 49,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -111,8 +111,7 @@
|
|||||||
{ "android": { "parentTheme": "Material3" } }
|
{ "android": { "parentTheme": "Material3" } }
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
["react-native-bottom-tabs"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
||||||
["./plugins/withGoogleCastActivity.js"]
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -77,20 +77,6 @@ export default function IndexLayout() {
|
|||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="settings/hide-libraries/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]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ 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 { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@@ -116,7 +116,7 @@ export default function index() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data: userViews,
|
||||||
isError: e1,
|
isError: e1,
|
||||||
isLoading: l1,
|
isLoading: l1,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
@@ -136,11 +136,6 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: mediaListCollections,
|
data: mediaListCollections,
|
||||||
isError: e2,
|
isError: e2,
|
||||||
|
|||||||
@@ -1,109 +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 { TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
storage.set("hasShownIntro", true);
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="bg-neutral-900 h-full py-32 px-4 space-y-4">
|
|
||||||
<View>
|
|
||||||
<Text className="text-3xl font-bold text-center mb-2">
|
|
||||||
Welcome to Streamyfin
|
|
||||||
</Text>
|
|
||||||
<Text className="text-center">
|
|
||||||
A free and open source client for Jellyfin.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-lg font-bold">Features</Text>
|
|
||||||
<Text className="text-xs">
|
|
||||||
Streamyfin has a bunch of features and integrates with a wide array of
|
|
||||||
software which you can find in the settings menu, these include:
|
|
||||||
</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">
|
|
||||||
Connect to your Jellyseerr instance and request movies directly in
|
|
||||||
the app.
|
|
||||||
</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">Downloads</Text>
|
|
||||||
<Text className="shrink text-xs">
|
|
||||||
Download movies and tv-shows to view offline. Use either the
|
|
||||||
default method or install the optimize server to download files in
|
|
||||||
the background.
|
|
||||||
</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">
|
|
||||||
Cast movies and tv-shows to your Chromecast devices.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
router.push("/settings");
|
|
||||||
}}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
<Text className="text-purple-600 text-center">Go to settings</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,22 +13,20 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
|||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
const onClearLogsClicked = async () => {
|
||||||
clearLogs();
|
clearLogs();
|
||||||
successHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -68,22 +66,6 @@ export default function settings() {
|
|||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/intro/page");
|
|
||||||
}}
|
|
||||||
title={"Show intro"}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
textColor="red"
|
|
||||||
onPress={() => {
|
|
||||||
storage.set("hasShownIntro", false);
|
|
||||||
}}
|
|
||||||
title={"Reset intro"}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<ListGroup title={"Logs"}>
|
<ListGroup title={"Logs"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
|
|||||||
@@ -1,61 +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";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<View 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">
|
|
||||||
Select the libraries you want to hide from the Library tab and home page
|
|
||||||
sections.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import {
|
|
||||||
router,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
useSegments,
|
|
||||||
} from "expo-router";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Animated } from "react-native";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { orderBy } from "lodash";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
|
||||||
import Poster from "@/components/posters/Poster";
|
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
|
||||||
|
|
||||||
const ANIMATION_ENTER = 250;
|
|
||||||
const ANIMATION_EXIT = 250;
|
|
||||||
const BACKDROP_DURATION = 5000;
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const segments = useSegments();
|
|
||||||
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
|
||||||
const from = segments[2];
|
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
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(
|
|
||||||
() =>
|
|
||||||
orderBy(
|
|
||||||
data?.combinedCredits?.cast,
|
|
||||||
["voteCount", "voteAverage"],
|
|
||||||
"desc"
|
|
||||||
),
|
|
||||||
[data?.combinedCredits]
|
|
||||||
);
|
|
||||||
|
|
||||||
const backdrops = useMemo(
|
|
||||||
() => castedRoles.map((c) => c.backdropPath),
|
|
||||||
[data?.combinedCredits]
|
|
||||||
);
|
|
||||||
|
|
||||||
const enterAnimation = useCallback(
|
|
||||||
() =>
|
|
||||||
Animated.timing(fadeAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: ANIMATION_ENTER,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
[fadeAnim]
|
|
||||||
);
|
|
||||||
|
|
||||||
const exitAnimation = useCallback(
|
|
||||||
() =>
|
|
||||||
Animated.timing(fadeAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: ANIMATION_EXIT,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
[fadeAnim]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (backdrops?.length) {
|
|
||||||
enterAnimation().start();
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
exitAnimation().start((end) => {
|
|
||||||
if (end.finished)
|
|
||||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length);
|
|
||||||
});
|
|
||||||
}, BACKDROP_DURATION);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
}, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
|
|
||||||
|
|
||||||
const viewDetails = (credit: PersonCreditCast) => {
|
|
||||||
router.push({
|
|
||||||
//@ts-ignore
|
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
|
||||||
//@ts-ignore
|
|
||||||
params: {
|
|
||||||
...credit,
|
|
||||||
mediaTitle: credit.title,
|
|
||||||
releaseYear: new Date(credit.releaseDate).getFullYear(),
|
|
||||||
canRequest: "false",
|
|
||||||
posterSrc: jellyseerrApi?.imageProxy(
|
|
||||||
credit.posterPath,
|
|
||||||
"w300_and_h450_face"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex-1 relative"
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParallaxScrollView
|
|
||||||
className="flex-1 opacity-100"
|
|
||||||
headerHeight={300}
|
|
||||||
headerImage={
|
|
||||||
<Animated.Image
|
|
||||||
source={{
|
|
||||||
uri: jellyseerrApi?.imageProxy(
|
|
||||||
backdrops?.[currentIndex],
|
|
||||||
"w1920_and_h800_multi_faces"
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
opacity: fadeAnim,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4 px-4">
|
|
||||||
<View className="flex flex-row justify-between w-full">
|
|
||||||
<View className="flex flex-col w-full">
|
|
||||||
<Text className="font-bold text-2xl mb-1">
|
|
||||||
{data?.details?.name}
|
|
||||||
</Text>
|
|
||||||
<Text className="opacity-50">
|
|
||||||
Born{" "}
|
|
||||||
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
{
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}
|
|
||||||
)}{" "}
|
|
||||||
| {data?.details?.placeOfBirth}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<OverviewText text={data?.details?.biography} className="mt-4" />
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<FlashList
|
|
||||||
data={castedRoles}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
|
||||||
<Text className="font-bold text-xl text-neutral-500">
|
|
||||||
No results
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
ListHeaderComponent={
|
|
||||||
<Text className="text-lg font-bold my-2">Appearances</Text>
|
|
||||||
}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-full flex flex-col pr-2"
|
|
||||||
onPress={() => viewDetails(item)}
|
|
||||||
>
|
|
||||||
<Poster
|
|
||||||
id={item.id.toString()}
|
|
||||||
url={jellyseerrApi?.imageProxy(item.posterPath)}
|
|
||||||
/>
|
|
||||||
<JellyseerrMediaIcon
|
|
||||||
className="absolute top-1 left-1"
|
|
||||||
mediaType={item.mediaType as "movie" | "tv"}
|
|
||||||
/>
|
|
||||||
{/*<Text numberOfLines={1}>{item.title}</Text>*/}
|
|
||||||
{item.character && (
|
|
||||||
<Text
|
|
||||||
className="text-xs opacity-50 align-bottom mt-1"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
as {item.character}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.id.toString()}
|
|
||||||
estimatedItemSize={255}
|
|
||||||
numColumns={3}
|
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
|
||||||
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import React, {
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
useCallback,
|
import { useLocalSearchParams } from "expo-router";
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -15,19 +9,14 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import {
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
MediaRequestStatus,
|
|
||||||
MediaStatus,
|
|
||||||
MediaType,
|
|
||||||
} from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackdropProps,
|
BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal, BottomSheetTextInput,
|
||||||
BottomSheetTextInput,
|
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import {
|
||||||
@@ -38,24 +27,24 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
const {
|
||||||
params as unknown as {
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest: canRequestString,
|
||||||
|
posterSrc,
|
||||||
|
...result
|
||||||
|
} = params as unknown as {
|
||||||
mediaTitle: string;
|
mediaTitle: string;
|
||||||
releaseYear: number;
|
releaseYear: number;
|
||||||
canRequest: string;
|
canRequest: string;
|
||||||
posterSrc: string;
|
posterSrc: string;
|
||||||
} & Partial<MovieResult | TvResult>;
|
} & Partial<MovieResult | TvResult>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const canRequest = canRequestString === "true";
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
@@ -66,7 +55,7 @@ const Page: React.FC = () => {
|
|||||||
data: details,
|
data: details,
|
||||||
isFetching,
|
isFetching,
|
||||||
isLoading,
|
isLoading,
|
||||||
refetch,
|
refetch
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
@@ -83,8 +72,6 @@ const Page: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const canRequest = useJellyseerrCanRequest(details);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -108,10 +95,9 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const request = useCallback(async () => {
|
const request = useCallback(
|
||||||
requestMedia(
|
async () => {
|
||||||
mediaTitle,
|
requestMedia(mediaTitle, {
|
||||||
{
|
|
||||||
mediaId: Number(result.id!!),
|
mediaId: Number(result.id!!),
|
||||||
mediaType: result.mediaType!!,
|
mediaType: result.mediaType!!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
@@ -120,20 +106,10 @@ const Page: React.FC = () => {
|
|||||||
?.map?.((s) => s.seasonNumber),
|
?.map?.((s) => s.seasonNumber),
|
||||||
},
|
},
|
||||||
refetch
|
refetch
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[details, result, requestMedia]
|
||||||
);
|
);
|
||||||
}, [details, result, requestMedia]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (details) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
|
||||||
<ItemActions item={details} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [details]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -157,10 +133,7 @@ const Page: React.FC = () => {
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||||
result.backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces"
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -209,9 +182,7 @@ const Page: React.FC = () => {
|
|||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
</View>
|
</View>
|
||||||
{isLoading || isFetching ? (
|
{canRequest ? (
|
||||||
<Button loading={true} disabled={true} color="purple"></Button>
|
|
||||||
) : canRequest ? (
|
|
||||||
<Button color="purple" onPress={request}>
|
<Button color="purple" onPress={request}>
|
||||||
Request
|
Request
|
||||||
</Button>
|
</Button>
|
||||||
@@ -242,11 +213,6 @@ const Page: React.FC = () => {
|
|||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
|
||||||
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
|
||||||
details={details}
|
|
||||||
/>
|
|
||||||
<Cast details={details} />
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
@@ -313,7 +279,9 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
<View
|
||||||
|
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full"
|
||||||
|
>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
multiline
|
multiline
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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";
|
||||||
|
|
||||||
@@ -23,20 +23,20 @@ export default function index() {
|
|||||||
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!)),
|
|
||||||
[data, settings?.hiddenLibraries]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const item of data || []) {
|
for (const item of data || []) {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
@@ -63,7 +63,7 @@ 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">No libraries found</Text>
|
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
||||||
@@ -81,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={() =>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export default function SearchLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
<Stack.Screen name="jellyseerr/[personId]" options={commonScreenOptions} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,18 +31,12 @@ 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 { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import {
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
MovieResult,
|
|
||||||
PersonResult,
|
|
||||||
TvResult,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Tag } from "@/components/GenreTags";
|
||||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||||
import { sortBy } from "lodash";
|
import { sortBy } from "lodash";
|
||||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
|
||||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -155,8 +149,8 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({
|
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
||||||
queryKey: ["search", "jellyseerr", "results", debouncedSearch],
|
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await jellyseerrApi?.search({
|
const response = await jellyseerrApi?.search({
|
||||||
query: new URLSearchParams(debouncedSearch).toString(),
|
query: new URLSearchParams(debouncedSearch).toString(),
|
||||||
@@ -172,9 +166,8 @@ export default function search() {
|
|||||||
debouncedSearch.length > 0,
|
debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } =
|
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
||||||
useReactNavigationQuery({
|
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
||||||
queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch],
|
|
||||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||||
enabled:
|
enabled:
|
||||||
!!jellyseerrApi &&
|
!!jellyseerrApi &&
|
||||||
@@ -198,14 +191,6 @@ export default function search() {
|
|||||||
[jellyseerrResults]
|
[jellyseerrResults]
|
||||||
);
|
);
|
||||||
|
|
||||||
const jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
jellyseerrResults?.filter(
|
|
||||||
(r) => r.mediaType === "person"
|
|
||||||
) as PersonResult[],
|
|
||||||
[jellyseerrResults]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: series, isFetching: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -315,7 +300,7 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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
|
||||||
@@ -501,19 +486,6 @@ export default function search() {
|
|||||||
<JellyseerrPoster item={item} key={item.id} />
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
|
||||||
header="Actors"
|
|
||||||
items={jellyseerrPersonResults}
|
|
||||||
renderItem={(item: PersonResult) => (
|
|
||||||
<PersonPoster
|
|
||||||
className="mr-2"
|
|
||||||
key={item.id}
|
|
||||||
id={item.id.toString()}
|
|
||||||
name={item.name}
|
|
||||||
posterPath={item.profilePath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useRef } from "react";
|
import React from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
@@ -13,13 +13,12 @@ const { Navigator } = createNativeBottomTabNavigator();
|
|||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -30,23 +29,6 @@ export const NativeTabs = withLayoutContext<
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
|
||||||
if (!hasShownIntro) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
router.push("/intro/page");
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style="light" />
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -68,11 +68,9 @@ export default function page() {
|
|||||||
const { getDownloadedItem } = useDownload();
|
const { getDownloadedItem } = useDownload();
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -177,7 +175,7 @@ export default function page() {
|
|||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
@@ -437,6 +435,7 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -45,8 +45,6 @@ export default function page() {
|
|||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
@@ -126,7 +124,7 @@ export default function page() {
|
|||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(
|
||||||
async (ticks: number) => {
|
async (ticks: number) => {
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -48,7 +48,6 @@ const Player = () => {
|
|||||||
|
|
||||||
const firstTime = useRef(true);
|
const firstTime = useRef(true);
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
@@ -59,7 +58,7 @@ const Player = () => {
|
|||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
@@ -168,7 +167,7 @@ const Player = () => {
|
|||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
@@ -388,6 +387,7 @@ const Player = () => {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
opacity: showControls ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{videoSource ? (
|
{videoSource ? (
|
||||||
|
|||||||
45
app/(auth)/trailer/page.tsx
Normal file
45
app/(auth)/trailer/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useGlobalSearchParams } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, Dimensions, View } from "react-native";
|
||||||
|
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const searchParams = useGlobalSearchParams();
|
||||||
|
|
||||||
|
const { url } = searchParams as { url: string };
|
||||||
|
|
||||||
|
const videoId = useMemo(() => {
|
||||||
|
return url.split("v=")[1];
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
|
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||||
|
if (state === "ended") {
|
||||||
|
setPlaying(false);
|
||||||
|
Alert.alert("video has finished playing!");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlaying = useCallback(() => {
|
||||||
|
setPlaying((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
togglePlaying();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||||
|
<YoutubePlayer
|
||||||
|
height={300}
|
||||||
|
play={playing}
|
||||||
|
videoId={videoId}
|
||||||
|
onChangeState={onStateChange}
|
||||||
|
width={screenWidth}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { orientationAtom } from "@/utils/atoms/orientation";
|
|||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
import { LogProvider, writeToLog } from "@/utils/log";
|
import { LogProvider, writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { formatItemName, 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";
|
||||||
@@ -336,6 +336,14 @@ function Layout() {
|
|||||||
header: () => null,
|
header: () => null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/trailer/page"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "modal",
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{
|
options={{
|
||||||
@@ -377,18 +385,15 @@ function Layout() {
|
|||||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
let items: BaseItemDto[] = downloadedItems
|
let items: { [key: string]: BaseItemDto } = downloadedItems
|
||||||
? JSON.parse(downloadedItems)
|
? JSON.parse(downloadedItems)
|
||||||
: [];
|
: {};
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
|
||||||
if (existingItemIndex !== -1) {
|
|
||||||
items[existingItemIndex] = item;
|
|
||||||
} else {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (item.Id) {
|
||||||
|
item.Path = `${FileSystem.documentDirectory}${formatItemName(item)}.mp4`;
|
||||||
|
items[item.Id] = item;
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
storage.set("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);
|
||||||
|
|||||||
102
app/login.tsx
102
app/login.tsx
@@ -2,13 +2,8 @@ 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 { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
Ionicons,
|
|
||||||
MaterialCommunityIcons,
|
|
||||||
MaterialIcons,
|
|
||||||
} 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 { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
@@ -44,6 +39,7 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
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;
|
||||||
@@ -81,10 +77,8 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className="flex flex-row items-center"
|
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
<Text className="ml-2 text-purple-600">Change server</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
@@ -101,9 +95,9 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert("Connection failed", error.message);
|
setError(error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert("Connection failed", "An unexpected error occurred");
|
setError("An unexpected error occurred");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -142,8 +136,6 @@ const Login: React.FC = () => {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
@@ -197,13 +189,13 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (api?.basePath) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
|
||||||
<>
|
|
||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
<View className="px-4 -mt-20 w-full">
|
<View className="px-4 -mt-20 w-full">
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
@@ -218,9 +210,7 @@ const Login: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">
|
<Text className="text-xs text-neutral-400">{api.basePath}</Text>
|
||||||
{api.basePath}
|
|
||||||
</Text>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -238,6 +228,7 @@ const Login: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
className="mb-2"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
@@ -251,34 +242,36 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<View className="flex flex-row items-center justify-between">
|
|
||||||
<Button
|
|
||||||
onPress={handleLogin}
|
|
||||||
loading={loading}
|
|
||||||
className="flex-1 mr-2"
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</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>
|
</View>
|
||||||
|
|
||||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></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
|
||||||
<View className="flex flex-col h-full items-center justify-center w-full">
|
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, height: "100%" }}
|
||||||
|
>
|
||||||
|
<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">
|
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
@@ -294,8 +287,7 @@ const Login: React.FC = () => {
|
|||||||
Enter the URL to your Jellyfin server
|
Enter the URL to your Jellyfin server
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label="Server URL"
|
placeholder="Server URL"
|
||||||
placeholder="http(s)://your-server.com"
|
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
@@ -304,7 +296,16 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
<Text className="text-xs text-neutral-500 ml-4">
|
||||||
|
Make sure to include http or https
|
||||||
|
</Text>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={(s) => {
|
||||||
|
handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||||
<Button
|
<Button
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
disabled={loadingServerCheck}
|
disabled={loadingServerCheck}
|
||||||
@@ -313,15 +314,8 @@ const Login: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={(s) => {
|
|
||||||
handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
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: 1.9 MiB |
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -37,14 +37,12 @@ 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={`
|
||||||
@@ -56,16 +54,14 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
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={`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
import {View, ViewProps} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -8,15 +8,14 @@ interface TagProps {
|
|||||||
textClass?: ViewProps["className"]
|
textClass?: ViewProps["className"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
||||||
text,
|
text,
|
||||||
textClass,
|
textClass,
|
||||||
textStyle,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||||
<Text className={textClass} style={textStyle}>{text}</Text>
|
<Text className={textClass}>{text}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import Animated, {
|
|||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -64,7 +64,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
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 goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
@@ -80,7 +79,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -29,11 +29,10 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||||
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
if (hapticFeedback) {
|
if (hapticFeedback) {
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}
|
}
|
||||||
onPress?.();
|
onPress?.();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
|
|||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="p-4 rounded-xl bg-neutral-900"
|
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
placeholderTextColor={"#9CA3AF"}
|
placeholderTextColor={"#9CA3AF"}
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import {
|
|||||||
BaseItemPerson,
|
BaseItemPerson,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren, useCallback } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -18,6 +16,8 @@ export const itemRouter = (
|
|||||||
item: BaseItemDto | BaseItemPerson,
|
item: BaseItemDto | BaseItemPerson,
|
||||||
from: string
|
from: string
|
||||||
) => {
|
) => {
|
||||||
|
console.log(item.Type, item?.CollectionType);
|
||||||
|
|
||||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
}
|
}
|
||||||
@@ -68,33 +68,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
|
||||||
|
|
||||||
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
|
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
},
|
|
||||||
async (selectedIndex) => {
|
|
||||||
if (selectedIndex === 0) {
|
|
||||||
await markAsPlayedStatus(true);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} else if (selectedIndex === 1) {
|
|
||||||
await markAsPlayedStatus(false);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, markAsPlayedStatus]);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
from === "(home)" ||
|
from === "(home)" ||
|
||||||
@@ -103,16 +80,78 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
from === "(favorites)"
|
from === "(favorites)"
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
|
<ContextMenu.Root>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onLongPress={showActionSheet}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
// @ts-expect-error
|
// @ts-ignore
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Content
|
||||||
|
avoidCollisions
|
||||||
|
alignOffset={0}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={false}
|
||||||
|
key={"content"}
|
||||||
|
>
|
||||||
|
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="item-1"
|
||||||
|
onSelect={() => {
|
||||||
|
markAsPlayedStatus(true);
|
||||||
|
}}
|
||||||
|
shouldDismissMenuOnSelect
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle key="item-1-title">
|
||||||
|
Mark as watched
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
<ContextMenu.ItemIcon
|
||||||
|
ios={{
|
||||||
|
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
||||||
|
pointSize: 18,
|
||||||
|
weight: "semibold",
|
||||||
|
scale: "medium",
|
||||||
|
hierarchicalColor: {
|
||||||
|
dark: "green", // Changed to green for "watched"
|
||||||
|
light: "green",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
androidIconName="checkmark-circle"
|
||||||
|
></ContextMenu.ItemIcon>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="item-2"
|
||||||
|
onSelect={() => {
|
||||||
|
markAsPlayedStatus(false);
|
||||||
|
}}
|
||||||
|
shouldDismissMenuOnSelect
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle key="item-2-title">
|
||||||
|
Mark as not watched
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
<ContextMenu.ItemIcon
|
||||||
|
ios={{
|
||||||
|
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
||||||
|
pointSize: 18, // Adjusted for better visibility
|
||||||
|
weight: "semibold",
|
||||||
|
scale: "medium",
|
||||||
|
hierarchicalColor: {
|
||||||
|
dark: "red", // Changed to red for "not watched"
|
||||||
|
light: "red",
|
||||||
|
},
|
||||||
|
// Removed paletteColors as it's not necessary in this case
|
||||||
|
}}
|
||||||
|
androidIconName="eye-slash"
|
||||||
|
></ContextMenu.ItemIcon>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +26,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item.Id!);
|
return storage.getString(item.Id!);
|
||||||
@@ -42,7 +41,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id);
|
||||||
successHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
openFile(item);
|
openFile(item);
|
||||||
@@ -44,7 +43,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id);
|
||||||
successHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
|||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -128,7 +128,6 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -154,7 +153,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const handleRoute = useCallback(() => {
|
const handleRoute = useCallback(() => {
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (url) router.push(url);
|
if (url) router.push(url);
|
||||||
}, [item, from]);
|
}, [item, from]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useEffect, useState} from "react";
|
|||||||
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||||
|
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mediaStatus?: MediaStatus;
|
mediaStatus?: MediaStatus;
|
||||||
@@ -9,7 +10,7 @@ interface Props {
|
|||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
showRequestIcon,
|
showRequestIcon,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -68,4 +69,4 @@ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default JellyseerrStatusIcon;
|
export default JellyseerrIconStatus;
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|
||||||
import React from "react";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
|
||||||
|
|
||||||
const CastSlide: React.FC<
|
|
||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
|
||||||
> = ({ details, ...props }) => {
|
|
||||||
return (
|
|
||||||
details?.credits?.cast?.length &&
|
|
||||||
details?.credits?.cast?.length > 0 && (
|
|
||||||
<View {...props}>
|
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast</Text>
|
|
||||||
<FlashList
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
data={details?.credits.cast}
|
|
||||||
ItemSeparatorComponent={() => <View className="w-2" />}
|
|
||||||
estimatedItemSize={15}
|
|
||||||
keyExtractor={(item) => item?.id?.toString()}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<PersonPoster
|
|
||||||
id={item.id.toString()}
|
|
||||||
posterPath={item.profilePath}
|
|
||||||
name={item.name}
|
|
||||||
subName={item.character}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CastSlide;
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { uniqBy } from "lodash";
|
|
||||||
import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|
||||||
import CountryFlag from "react-native-country-flag";
|
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
|
||||||
|
|
||||||
interface Release {
|
|
||||||
certification: string;
|
|
||||||
iso_639_1?: string;
|
|
||||||
note?: string;
|
|
||||||
release_date: string;
|
|
||||||
type: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
};
|
|
||||||
|
|
||||||
const Facts: React.FC<
|
|
||||||
{ title: string; facts?: string[] | React.ReactNode[] } & ViewProps
|
|
||||||
> = ({ title, facts, ...props }) =>
|
|
||||||
facts &&
|
|
||||||
facts?.length > 0 && (
|
|
||||||
<View className="flex flex-row justify-between py-2" {...props}>
|
|
||||||
<Text className="font-bold">{title}</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col items-end">
|
|
||||||
{facts.map((f, idx) =>
|
|
||||||
typeof f === "string" ? <Text key={idx}>{f}</Text> : f
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
|
||||||
title,
|
|
||||||
fact,
|
|
||||||
...props
|
|
||||||
}) => fact && <Facts title={title} facts={[fact]} {...props} />;
|
|
||||||
|
|
||||||
const DetailFacts: React.FC<
|
|
||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
|
||||||
> = ({ details, className, ...props }) => {
|
|
||||||
const { jellyseerrUser } = useJellyseerr();
|
|
||||||
|
|
||||||
const locale = useMemo(() => {
|
|
||||||
return jellyseerrUser?.settings?.locale || "en";
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const region = useMemo(
|
|
||||||
() => jellyseerrUser?.settings?.region || "US",
|
|
||||||
[jellyseerrUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
const releases = useMemo(
|
|
||||||
() =>
|
|
||||||
(details as MovieDetails)?.releases?.results.find(
|
|
||||||
(r: TmdbRelease) => r.iso_3166_1 === region
|
|
||||||
)?.release_dates as TmdbRelease["release_dates"],
|
|
||||||
[details]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Release date types:
|
|
||||||
// 1. Premiere
|
|
||||||
// 2. Theatrical (limited)
|
|
||||||
// 3. Theatrical
|
|
||||||
// 4. Digital
|
|
||||||
// 5. Physical
|
|
||||||
// 6. TV
|
|
||||||
const filteredReleases = useMemo(
|
|
||||||
() =>
|
|
||||||
uniqBy(
|
|
||||||
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
|
|
||||||
"type"
|
|
||||||
),
|
|
||||||
[releases]
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstAirDate = useMemo(() => {
|
|
||||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
|
||||||
if (firstAirDate) {
|
|
||||||
return new Date(firstAirDate).toLocaleDateString(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
dateOpts
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [details]);
|
|
||||||
|
|
||||||
const nextAirDate = useMemo(() => {
|
|
||||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
|
||||||
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
|
||||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
|
||||||
return new Date(nextAirDate).toLocaleDateString(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
dateOpts
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [details]);
|
|
||||||
|
|
||||||
const revenue = useMemo(
|
|
||||||
() =>
|
|
||||||
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
{ style: "currency", currency: "USD" }
|
|
||||||
),
|
|
||||||
[details]
|
|
||||||
);
|
|
||||||
|
|
||||||
const budget = useMemo(
|
|
||||||
() =>
|
|
||||||
(details as MovieDetails)?.budget?.toLocaleString?.(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
{ style: "currency", currency: "USD" }
|
|
||||||
),
|
|
||||||
[details]
|
|
||||||
);
|
|
||||||
|
|
||||||
const streamingProviders = useMemo(
|
|
||||||
() =>
|
|
||||||
details?.watchProviders?.find(
|
|
||||||
(provider) => provider.iso_3166_1 === region
|
|
||||||
)?.flatrate,
|
|
||||||
[details]
|
|
||||||
);
|
|
||||||
|
|
||||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
|
||||||
|
|
||||||
const spokenLanguage = useMemo(
|
|
||||||
() =>
|
|
||||||
details?.spokenLanguages.find(
|
|
||||||
(lng) => lng.iso_639_1 === details.originalLanguage
|
|
||||||
)?.name,
|
|
||||||
[details]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
details && (
|
|
||||||
<View className="p-4">
|
|
||||||
<Text className="text-lg font-bold">Details</Text>
|
|
||||||
<View
|
|
||||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Fact title="Status" fact={details?.status} />
|
|
||||||
<Fact
|
|
||||||
title="Original Title"
|
|
||||||
fact={(details as TvDetails)?.originalName}
|
|
||||||
/>
|
|
||||||
{details.keywords.some(
|
|
||||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
|
||||||
) && <Fact title="Series Type" fact="Anime" />}
|
|
||||||
<Facts
|
|
||||||
title="Release Dates"
|
|
||||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
|
||||||
<View key={idx} className="flex flex-row space-x-2 items-center">
|
|
||||||
{r.type === 3 ? (
|
|
||||||
// Theatrical
|
|
||||||
<Ionicons name="ticket" size={16} color="white" />
|
|
||||||
) : r.type === 4 ? (
|
|
||||||
// Digital
|
|
||||||
<Ionicons name="cloud" size={16} color="white" />
|
|
||||||
) : (
|
|
||||||
// Physical
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="record-circle-outline"
|
|
||||||
size={16}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text>
|
|
||||||
{new Date(r.release_date).toLocaleDateString(
|
|
||||||
`${locale}-${region}`,
|
|
||||||
dateOpts
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
/>
|
|
||||||
<Fact title="First Air Date" fact={firstAirDate} />
|
|
||||||
<Fact title="Next Air Date" fact={nextAirDate} />
|
|
||||||
<Fact title="Revenue" fact={revenue} />
|
|
||||||
<Fact title="Budget" fact={budget} />
|
|
||||||
<Fact title="Original Language" fact={spokenLanguage} />
|
|
||||||
<Facts
|
|
||||||
title="Production Country"
|
|
||||||
facts={details?.productionCountries?.map((n, idx) => (
|
|
||||||
<View key={idx} className="flex flex-row items-center space-x-2">
|
|
||||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
|
||||||
<Text>{n.name}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
/>
|
|
||||||
<Facts
|
|
||||||
title="Studios"
|
|
||||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
|
||||||
(n) => n.name
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Facts title="Network" facts={networks?.map((n) => n.name)} />
|
|
||||||
<Facts
|
|
||||||
title="Currently Streaming on"
|
|
||||||
facts={streamingProviders?.map((s) => s.name)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DetailFacts;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {useMemo} from "react";
|
|
||||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import {Feather, MaterialCommunityIcons} from "@expo/vector-icons";
|
|
||||||
import {View, ViewProps} from "react-native";
|
|
||||||
|
|
||||||
const JellyseerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
|
||||||
mediaType,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const style = useMemo(
|
|
||||||
() => mediaType === MediaType.MOVIE
|
|
||||||
? 'bg-blue-600/90 border-blue-400/40'
|
|
||||||
: 'bg-purple-600/90 border-purple-400/40',
|
|
||||||
[mediaType]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
mediaType &&
|
|
||||||
<View className={`${className} border ${style} rounded-full p-1`} {...props}>
|
|
||||||
{mediaType === MediaType.MOVIE ? (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="movie-open"
|
|
||||||
size={16}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Feather
|
|
||||||
size={16}
|
|
||||||
name="tv"
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default JellyseerrMediaIcon;
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
|
||||||
import React from "react";
|
|
||||||
import {Text} from "@/components/common/Text";
|
|
||||||
import Poster from "@/components/posters/Poster";
|
|
||||||
import {useRouter, useSegments} from "expo-router";
|
|
||||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string
|
|
||||||
posterPath?: string
|
|
||||||
name: string
|
|
||||||
subName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PersonPoster: React.FC<Props & ViewProps> = ({
|
|
||||||
id,
|
|
||||||
posterPath,
|
|
||||||
name,
|
|
||||||
subName,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const {jellyseerrApi} = useJellyseerr();
|
|
||||||
const router = useRouter();
|
|
||||||
const segments = useSegments();
|
|
||||||
const from = segments[2];
|
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
|
||||||
return (
|
|
||||||
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}>
|
|
||||||
<View className="flex flex-col w-28" {...props}>
|
|
||||||
<Poster
|
|
||||||
id={id}
|
|
||||||
url={jellyseerrApi?.imageProxy(posterPath, 'w600_and_h900_bestv2')}
|
|
||||||
/>
|
|
||||||
<Text className="mt-2">{name}</Text>
|
|
||||||
{subName && <Text className="text-xs opacity-50">{subName}</Text>}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PersonPoster;
|
|
||||||
@@ -1,47 +1,55 @@
|
|||||||
import {View, ViewProps} from "react-native";
|
import {View, ViewProps} from "react-native";
|
||||||
import {Image} from "expo-image";
|
import {Image} from "expo-image";
|
||||||
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
import {Text} from "@/components/common/Text";
|
import {Text} from "@/components/common/Text";
|
||||||
import { useMemo } from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import {
|
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||||
MediaStatus,
|
|
||||||
MediaType,
|
|
||||||
} from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
import {
|
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
hasPermission,
|
|
||||||
Permission,
|
|
||||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
|
||||||
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
||||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
const JellyseerrPoster: React.FC<Props> = ({
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
item,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
|
||||||
|
// const imageSource =
|
||||||
|
|
||||||
const imageSrc = useMemo(
|
const imageSrc = useMemo(() =>
|
||||||
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
|
item.posterPath ?
|
||||||
|
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
|
||||||
|
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
|
||||||
[item, jellyseerrApi]
|
[item, jellyseerrApi]
|
||||||
);
|
)
|
||||||
const title = useMemo(
|
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
|
||||||
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
|
const releaseYear = useMemo(() =>
|
||||||
|
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
|
||||||
[item]
|
[item]
|
||||||
);
|
)
|
||||||
const releaseYear = useMemo(
|
|
||||||
() =>
|
|
||||||
new Date(
|
|
||||||
item.mediaType === MediaType.MOVIE
|
|
||||||
? item.releaseDate
|
|
||||||
: item.firstAirDate
|
|
||||||
).getFullYear(),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canRequest = useJellyseerrCanRequest(item);
|
const showRequestButton = useMemo(() =>
|
||||||
|
jellyseerrUser && hasPermission(
|
||||||
|
[
|
||||||
|
Permission.REQUEST,
|
||||||
|
item.mediaType === 'movie'
|
||||||
|
? Permission.REQUEST_MOVIE
|
||||||
|
: Permission.REQUEST_TV,
|
||||||
|
],
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
{type: 'or'}
|
||||||
|
),
|
||||||
|
[item, jellyseerrUser]
|
||||||
|
)
|
||||||
|
|
||||||
|
const canRequest = useMemo(() => {
|
||||||
|
const status = item?.mediaInfo?.status
|
||||||
|
return showRequestButton && !status || status === MediaStatus.UNKNOWN
|
||||||
|
}, [item])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableJellyseerrRouter
|
<TouchableJellyseerrRouter
|
||||||
@@ -49,7 +57,7 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
mediaTitle={title}
|
mediaTitle={title}
|
||||||
releaseYear={releaseYear}
|
releaseYear={releaseYear}
|
||||||
canRequest={canRequest}
|
canRequest={canRequest}
|
||||||
posterSrc={imageSrc!!}
|
posterSrc={imageSrc}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col w-28 mr-2">
|
<View className="flex flex-col w-28 mr-2">
|
||||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||||
@@ -64,24 +72,21 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<JellyseerrStatusIcon
|
<JellyseerrIconStatus
|
||||||
className="absolute bottom-1 right-1"
|
className="absolute bottom-1 right-1"
|
||||||
showRequestIcon={canRequest}
|
showRequestIcon={canRequest}
|
||||||
mediaStatus={item?.mediaInfo?.status}
|
mediaStatus={item?.mediaInfo?.status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JellyseerrMediaIcon
|
|
||||||
className="absolute top-1 left-1"
|
|
||||||
mediaType={item?.mediaType}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="mt-2 flex flex-col">
|
<View className="mt-2 flex flex-col">
|
||||||
<Text numberOfLines={2}>{title}</Text>
|
<Text numberOfLines={2}>{title}</Text>
|
||||||
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
|
<Text className="text-xs opacity-50">{releaseYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableJellyseerrRouter>
|
</TouchableJellyseerrRouter>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
export default JellyseerrPoster;
|
export default JellyseerrPoster;
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemPerson,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
type PosterProps = {
|
type PosterProps = {
|
||||||
id?: string | null;
|
item?: BaseItemDto | BaseItemPerson | null;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
blurhash?: string | null;
|
blurhash?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
|
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||||
if (!id && !url)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="border border-neutral-900"
|
className="border border-neutral-900"
|
||||||
@@ -29,8 +33,8 @@ const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
key={id}
|
key={item.Id}
|
||||||
id={id!!}
|
id={item.Id}
|
||||||
source={
|
source={
|
||||||
url
|
url
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<Poster id={i.id} url={getPrimaryImageUrl({ api, item: i })} />
|
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
|
||||||
<Text className="mt-2">{i.Name}</Text>
|
<Text className="mt-2">{i.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{i.Role}</Text>
|
<Text className="text-xs opacity-50">{i.Role}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
className="flex flex-col space-y-2 w-28"
|
className="flex flex-col space-y-2 w-28"
|
||||||
>
|
>
|
||||||
<Poster
|
<Poster
|
||||||
id={item.id}
|
item={item}
|
||||||
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
|
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
|
||||||
/>
|
/>
|
||||||
<Text>{item.SeriesName}</Text>
|
<Text>{item.SeriesName}</Text>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { orderBy } from "lodash";
|
import { orderBy } from "lodash";
|
||||||
import { Tags } from "@/components/GenreTags";
|
import { Tags } from "@/components/GenreTags";
|
||||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||||
import Season from "@/utils/jellyseerr/server/entity/Season";
|
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||||
import {
|
import {
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -61,7 +61,7 @@ const RenderItem = ({ item, index }: any) => {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
@@ -246,7 +246,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||||
?.status === MediaStatus.UNKNOWN;
|
?.status === MediaStatus.UNKNOWN;
|
||||||
return (
|
return (
|
||||||
<JellyseerrStatusIcon
|
<JellyseerrIconStatus
|
||||||
key={0}
|
key={0}
|
||||||
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
||||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||||
|
|||||||
@@ -1,45 +1,24 @@
|
|||||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
Alert,
|
|
||||||
Linking,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto | MovieDetails | TvDetails;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemActions = ({ item, ...props }: Props) => {
|
export const ItemActions = ({ item, ...props }: Props) => {
|
||||||
const trailerLink = useMemo(() => {
|
const router = useRouter();
|
||||||
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
|
|
||||||
return item.RemoteTrailers[0].Url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("relatedVideos" in item) {
|
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
|
||||||
return item.relatedVideos?.find((v) => v.type === "Trailer")?.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const openTrailer = useCallback(async () => {
|
const openTrailer = useCallback(async () => {
|
||||||
if (!trailerLink) {
|
if (!trailerLink) return;
|
||||||
Alert.alert("No trailer available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const encodedTrailerLink = encodeURIComponent(trailerLink);
|
||||||
await Linking.openURL(trailerLink);
|
router.push(`/trailer/page?url=${encodedTrailerLink}`);
|
||||||
} catch (err) {
|
}, [router, trailerLink]);
|
||||||
console.error("Failed to open trailer link:", err);
|
|
||||||
}
|
|
||||||
}, [trailerLink]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="" {...props}>
|
<View className="" {...props}>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
@@ -20,7 +19,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const OtherSettings: React.FC = () => {
|
export const OtherSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
/********************
|
/********************
|
||||||
@@ -56,7 +54,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title="Other" className="">
|
<ListGroup title="Other" className="mb-4">
|
||||||
<ListItem title="Auto rotate">
|
<ListItem title="Auto rotate">
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.autoRotate}
|
value={settings.autoRotate}
|
||||||
@@ -180,19 +178,6 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
|
||||||
title="Hide Libraries"
|
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem title="Disable Haptic Feedback">
|
|
||||||
<Switch
|
|
||||||
value={settings.disableHapticFeedback}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ disableHapticFeedback: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useRef, useState } from "react";
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
import { Alert, View, ViewProps } from "react-native";
|
||||||
@@ -23,8 +23,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
const errorHapticFeedback = useHaptic("error");
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -45,16 +43,16 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
successHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
Alert.alert("Success", "Quick connect authorized");
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
Alert.alert("Error", "Invalid code");
|
Alert.alert("Error", "Invalid code");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
Alert.alert("Error", "Invalid code");
|
Alert.alert("Error", "Invalid code");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import * as Progress from "react-native-progress";
|
import * as Progress from "react-native-progress";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -13,8 +13,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
|
|
||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
const errorHapticFeedback = useHaptic("error");
|
|
||||||
|
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
const { data: size, isLoading: appSizeLoading } = useQuery({
|
||||||
queryKey: ["appSize", appSizeUsage],
|
queryKey: ["appSize", appSizeUsage],
|
||||||
@@ -31,9 +29,9 @@ export const StorageSettings = () => {
|
|||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
successHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorHapticFeedback();
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
toast.error("Error deleting files");
|
toast.error("Error deleting files");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -157,12 +157,10 @@ export const Controls: React.FC<Props> = ({
|
|||||||
isVlc
|
isVlc
|
||||||
);
|
);
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
if (!previousItem || !settings) return;
|
if (!previousItem || !settings) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
@@ -200,7 +198,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
if (!nextItem || !settings) return;
|
if (!nextItem || !settings) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
@@ -328,7 +326,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const handleSkipBackward = useCallback(async () => {
|
const handleSkipBackward = useCallback(async () => {
|
||||||
if (!settings?.rewindSkipTime) return;
|
if (!settings?.rewindSkipTime) return;
|
||||||
wasPlayingRef.current = isPlaying;
|
wasPlayingRef.current = isPlaying;
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
@@ -346,7 +344,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const handleSkipForward = useCallback(async () => {
|
const handleSkipForward = useCallback(async () => {
|
||||||
if (!settings?.forwardSkipTime) return;
|
if (!settings?.forwardSkipTime) return;
|
||||||
wasPlayingRef.current = isPlaying;
|
wasPlayingRef.current = isPlaying;
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
@@ -363,7 +361,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const memoizedRenderBubble = useCallback(() => {
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
@@ -442,7 +440,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const gotoItem = await getItemById(api, itemId);
|
const gotoItem = await getItemById(api, itemId);
|
||||||
if (!settings || !gotoItem) return;
|
if (!settings || !gotoItem) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
@@ -499,6 +497,33 @@ export const Controls: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<VideoProvider
|
||||||
|
getAudioTracks={getAudioTracks}
|
||||||
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
|
setAudioTrack={setAudioTrack}
|
||||||
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
|
setSubtitleURL={setSubtitleURL}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||||
|
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||||
|
>
|
||||||
|
{!mediaSource?.TranscodingUrl ? (
|
||||||
|
<DropdownViewDirect showControls={showControls} />
|
||||||
|
) : (
|
||||||
|
<DropdownViewTranscoding showControls={showControls} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</VideoProvider>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPressIn={() => {
|
onPressIn={() => {
|
||||||
toggleControls();
|
toggleControls();
|
||||||
@@ -507,8 +532,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: Dimensions.get("window").width,
|
width: Dimensions.get("window").width,
|
||||||
height: Dimensions.get("window").height,
|
height: Dimensions.get("window").height,
|
||||||
backgroundColor: "black",
|
|
||||||
opacity: showControls ? 0.5 : 0,
|
|
||||||
}}
|
}}
|
||||||
></Pressable>
|
></Pressable>
|
||||||
|
|
||||||
@@ -518,32 +541,12 @@ export const Controls: React.FC<Props> = ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||||
width: settings?.safeAreaInControlsEnabled
|
|
||||||
? Dimensions.get("window").width - insets.left - insets.right
|
|
||||||
: Dimensions.get("window").width,
|
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-row w-full p-4 `}
|
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||||
>
|
>
|
||||||
<View className="mr-auto">
|
|
||||||
<VideoProvider
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
>
|
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
|
||||||
<DropdownViewDirect showControls={showControls} />
|
|
||||||
) : (
|
|
||||||
<DropdownViewTranscoding showControls={showControls} />
|
|
||||||
)}
|
|
||||||
</VideoProvider>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center space-x-2 ">
|
|
||||||
{item?.Type === "Episode" && !offline && (
|
{item?.Type === "Episode" && !offline && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -586,7 +589,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
@@ -594,7 +597,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
<Ionicons name="close" size={24} color="white" />
|
<Ionicons name="close" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -118,7 +118,14 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 1000,
|
||||||
|
opacity: showControls ? 1 : 0,
|
||||||
|
}}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.24.0",
|
"channel": "0.23.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.24.0",
|
"channel": "0.23.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import { useHaptic } from "./useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Introduction: {
|
Introduction: {
|
||||||
@@ -29,7 +29,6 @@ export const useCreditSkipper = (
|
|||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
if (isVlc) {
|
if (isVlc) {
|
||||||
currentTime = msToSeconds(currentTime);
|
currentTime = msToSeconds(currentTime);
|
||||||
@@ -80,7 +79,7 @@ export const useCreditSkipper = (
|
|||||||
if (!creditTimestamps) return;
|
if (!creditTimestamps) return;
|
||||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||||
try {
|
try {
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
wrappedSeek(creditTimestamps.Credits.End);
|
wrappedSeek(creditTimestamps.Credits.End);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
// Used only for initial play settings.
|
// Used only for intial play settings.
|
||||||
const useDefaultPlaySettings = (
|
const useDefaultPlaySettings = (
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
settings: Settings | null
|
settings: Settings | null
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { getFilePathFromItemId } from "@/utils/mmkv";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -17,13 +18,13 @@ export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = await FileSystem.readDirectoryAsync(directory);
|
const files = await FileSystem.readDirectoryAsync(directory);
|
||||||
const path = itemId!;
|
const filePath = getFilePathFromItemId(itemId);
|
||||||
const matchingFile = files.find((file) => file.startsWith(path));
|
|
||||||
|
const matchingFile = files.find((file) => file === filePath);
|
||||||
|
|
||||||
if (!matchingFile) {
|
if (!matchingFile) {
|
||||||
throw new Error(`No file found for item ${path}`);
|
throw new Error(`No file found for item ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${directory}${matchingFile}`;
|
return `${directory}${matchingFile}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export type HapticFeedbackType =
|
|
||||||
| "light"
|
|
||||||
| "medium"
|
|
||||||
| "heavy"
|
|
||||||
| "selection"
|
|
||||||
| "success"
|
|
||||||
| "warning"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const createHapticHandler = useCallback(
|
|
||||||
(type: Haptics.ImpactFeedbackStyle) => {
|
|
||||||
return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const createNotificationFeedback = useCallback(
|
|
||||||
(type: Haptics.NotificationFeedbackType) => {
|
|
||||||
return Platform.OS === "web"
|
|
||||||
? () => {}
|
|
||||||
: () => Haptics.notificationAsync(type);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hapticHandlers = useMemo(
|
|
||||||
() => ({
|
|
||||||
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
|
||||||
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
|
||||||
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
|
||||||
selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync,
|
|
||||||
success: createNotificationFeedback(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
),
|
|
||||||
warning: createNotificationFeedback(
|
|
||||||
Haptics.NotificationFeedbackType.Warning
|
|
||||||
),
|
|
||||||
error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
|
|
||||||
}),
|
|
||||||
[createHapticHandler, createNotificationFeedback]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (settings?.disableHapticFeedback) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
return hapticHandlers[feedbackType];
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import { useHaptic } from "./useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
EpisodeId: string;
|
EpisodeId: string;
|
||||||
@@ -33,7 +33,6 @@ export const useIntroSkipper = (
|
|||||||
if (isVlc) {
|
if (isVlc) {
|
||||||
currentTime = msToSeconds(currentTime);
|
currentTime = msToSeconds(currentTime);
|
||||||
}
|
}
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const wrappedSeek = (seconds: number) => {
|
const wrappedSeek = (seconds: number) => {
|
||||||
if (isVlc) {
|
if (isVlc) {
|
||||||
@@ -79,7 +78,7 @@ export const useIntroSkipper = (
|
|||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
wrappedSeek(introTimestamps.IntroEnd);
|
wrappedSeek(introTimestamps.IntroEnd);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue";
|
|||||||
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||||
import { writeErrorLog } from "@/utils/log";
|
import { writeErrorLog } from "@/utils/log";
|
||||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
import {
|
|
||||||
CombinedCredit,
|
|
||||||
PersonDetails,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Person";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
interface SearchParams {
|
interface SearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -60,8 +55,6 @@ export enum Endpoints {
|
|||||||
API_V1 = "/api/v1",
|
API_V1 = "/api/v1",
|
||||||
SEARCH = "/search",
|
SEARCH = "/search",
|
||||||
REQUEST = "/request",
|
REQUEST = "/request",
|
||||||
PERSON = "/person",
|
|
||||||
COMBINED_CREDITS = "/combined_credits",
|
|
||||||
MOVIE = "/movie",
|
MOVIE = "/movie",
|
||||||
RATINGS = "/ratings",
|
RATINGS = "/ratings",
|
||||||
ISSUE = "/issue",
|
ISSUE = "/issue",
|
||||||
@@ -211,27 +204,6 @@ export class JellyseerrApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async personDetails(id: number | string): Promise<PersonDetails> {
|
|
||||||
return this.axios
|
|
||||||
?.get<PersonDetails>(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`)
|
|
||||||
.then((response) => {
|
|
||||||
return response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async personCombinedCredits(id: number | string): Promise<CombinedCredit> {
|
|
||||||
return this.axios
|
|
||||||
?.get<CombinedCredit>(
|
|
||||||
Endpoints.API_V1 +
|
|
||||||
Endpoints.PERSON +
|
|
||||||
`/${id}` +
|
|
||||||
Endpoints.COMBINED_CREDITS
|
|
||||||
)
|
|
||||||
.then((response) => {
|
|
||||||
return response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async movieRatings(id: number) {
|
async movieRatings(id: number) {
|
||||||
return this.axios
|
return this.axios
|
||||||
?.get<RTRating>(
|
?.get<RTRating>(
|
||||||
@@ -266,20 +238,14 @@ export class JellyseerrApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
imageProxy(
|
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
|
||||||
path?: string,
|
return (
|
||||||
tmdbPath: string = "original",
|
this.axios.defaults.baseURL +
|
||||||
width: number = 1920,
|
|
||||||
quality: number = 75
|
|
||||||
) {
|
|
||||||
return path
|
|
||||||
? this.axios.defaults.baseURL +
|
|
||||||
`/_next/image?` +
|
`/_next/image?` +
|
||||||
new URLSearchParams(
|
new URLSearchParams(
|
||||||
`url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}`
|
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
|
||||||
).toString()
|
).toString()
|
||||||
: this.axios?.defaults.baseURL +
|
);
|
||||||
`/images/overseerr_poster_not_found_logo_top.png`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
|
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
|
||||||
@@ -355,7 +321,6 @@ const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
|||||||
export const useJellyseerr = () => {
|
export const useJellyseerr = () => {
|
||||||
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const jellyseerrApi = useMemo(() => {
|
const jellyseerrApi = useMemo(() => {
|
||||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
@@ -373,16 +338,12 @@ export const useJellyseerr = () => {
|
|||||||
|
|
||||||
const requestMedia = useCallback(
|
const requestMedia = useCallback(
|
||||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||||
jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["search", "jellyseerr"],
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (mediaRequest.status) {
|
switch (mediaRequest.status) {
|
||||||
case MediaRequestStatus.PENDING:
|
case MediaRequestStatus.PENDING:
|
||||||
case MediaRequestStatus.APPROVED:
|
case MediaRequestStatus.APPROVED:
|
||||||
toast.success(`Requested ${title}!`);
|
toast.success(`Requested ${title}!`);
|
||||||
onSuccess?.();
|
onSuccess?.()
|
||||||
break;
|
break;
|
||||||
case MediaRequestStatus.DECLINED:
|
case MediaRequestStatus.DECLINED:
|
||||||
toast.error(`You don't have permission to request!`);
|
toast.error(`You don't have permission to request!`);
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
|||||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||||
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 { useHaptic } from "./useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
const queriesToInvalidate = [
|
const queriesToInvalidate = [
|
||||||
@@ -30,7 +29,7 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = async (played: boolean) => {
|
const markAsPlayedStatus = async (played: boolean) => {
|
||||||
lightHapticFeedback();
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import useDownloadHelper from "@/utils/download";
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
|
import { formatItemName } from "@/utils/mmkv";
|
||||||
|
|
||||||
const createFFmpegCommand = (url: string, output: string) => [
|
const createFFmpegCommand = (url: string, output: string) => [
|
||||||
"-y", // overwrite output files without asking
|
"-y", // overwrite output files without asking
|
||||||
@@ -53,7 +54,12 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload();
|
const {
|
||||||
|
saveDownloadedItemInfo,
|
||||||
|
setProcesses,
|
||||||
|
processes,
|
||||||
|
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||||
|
} = useDownload();
|
||||||
|
|
||||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||||
await saveSeriesPrimaryImage(item);
|
await saveSeriesPrimaryImage(item);
|
||||||
@@ -73,13 +79,12 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
try {
|
try {
|
||||||
console.log("completeCallback");
|
console.log("completeCallback");
|
||||||
const returnCode = await session.getReturnCode();
|
const returnCode = await session.getReturnCode();
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
const stat = await session.getLastReceivedStatistics();
|
const stat = await session.getLastReceivedStatistics();
|
||||||
await FileSystem.moveAsync({
|
await FileSystem.moveAsync({
|
||||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`
|
to: `${FileSystem.documentDirectory}${formatItemName(item)}.mp4`,
|
||||||
})
|
});
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["downloadedItems"],
|
queryKey: ["downloadedItems"],
|
||||||
});
|
});
|
||||||
@@ -131,12 +136,16 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
const cacheDir = await FileSystem.getInfoAsync(
|
||||||
|
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||||
|
);
|
||||||
if (!cacheDir.exists) {
|
if (!cacheDir.exists) {
|
||||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true})
|
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||||
|
intermediates: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`
|
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`;
|
||||||
|
|
||||||
if (!api) throw new Error("API is not defined");
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
|
|||||||
@@ -75,15 +75,14 @@
|
|||||||
"react-native-bottom-tabs": "0.7.1",
|
"react-native-bottom-tabs": "0.7.1",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-compressor": "^1.9.0",
|
"react-native-compressor": "^1.9.0",
|
||||||
"react-native-country-flag": "^2.0.2",
|
|
||||||
"react-native-device-info": "^14.0.1",
|
"react-native-device-info": "^14.0.1",
|
||||||
"react-native-edge-to-edge": "^1.1.3",
|
"react-native-edge-to-edge": "^1.1.1",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
"react-native-google-cast": "^4.8.3",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^2.5.2",
|
"react-native-ios-context-menu": "^2.5.2",
|
||||||
"react-native-ios-utilities": "4.5.3",
|
"react-native-ios-utilities": "^4.5.1",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"react-native-mmkv": "^2.12.2",
|
||||||
"react-native-pager-view": "6.3.0",
|
"react-native-pager-view": "6.3.0",
|
||||||
"react-native-progress": "^5.0.1",
|
"react-native-progress": "^5.0.1",
|
||||||
@@ -100,6 +99,7 @@
|
|||||||
"react-native-volume-manager": "^1.10.0",
|
"react-native-volume-manager": "^1.10.0",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "13.8.6",
|
"react-native-webview": "13.8.6",
|
||||||
|
"react-native-youtube-iframe": "^2.3.0",
|
||||||
"sonner-native": "^0.14.2",
|
"sonner-native": "^0.14.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
const { withAndroidManifest } = require("@expo/config-plugins");
|
|
||||||
|
|
||||||
const withGoogleCastActivity = (config) =>
|
|
||||||
withAndroidManifest(config, async (config) => {
|
|
||||||
const mainApplication = config.modResults.manifest.application[0];
|
|
||||||
|
|
||||||
// Initialize activity array if it doesn't exist
|
|
||||||
if (!mainApplication.activity) {
|
|
||||||
mainApplication.activity = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the activity already exists
|
|
||||||
const activityExists = mainApplication.activity.some(
|
|
||||||
(activity) =>
|
|
||||||
activity.$?.["android:name"] ===
|
|
||||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only add the activity if it doesn't already exist
|
|
||||||
if (!activityExists) {
|
|
||||||
mainApplication.activity.push({
|
|
||||||
$: {
|
|
||||||
"android:name":
|
|
||||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity",
|
|
||||||
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
|
|
||||||
"android:launchMode": "singleTask",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = withGoogleCastActivity;
|
|
||||||
@@ -19,14 +19,7 @@ import {
|
|||||||
download,
|
download,
|
||||||
setConfig,
|
setConfig,
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
} from "@kesha-antonov/react-native-background-downloader";
|
||||||
import MMKV from "react-native-mmkv";
|
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
|
||||||
focusManager,
|
|
||||||
QueryClient,
|
|
||||||
QueryClientProvider,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -45,17 +38,20 @@ import { apiAtom } from "./JellyfinProvider";
|
|||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import useImageStorage from "@/hooks/useImageStorage";
|
import useImageStorage from "@/hooks/useImageStorage";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { formatItemName, saveItemMapping, storage } from "@/utils/mmkv";
|
||||||
import useDownloadHelper from "@/utils/download";
|
import useDownloadHelper from "@/utils/download";
|
||||||
import { FileInfo } from "expo-file-system";
|
import { FileInfo } from "expo-file-system";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import * as Haptics from "expo-haptics";
|
||||||
import * as Application from "expo-application";
|
import * as Application from "expo-application";
|
||||||
|
|
||||||
export type DownloadedItem = {
|
export type DownloadedItem = {
|
||||||
item: Partial<BaseItemDto>;
|
item: Partial<BaseItemDto>;
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
|
fileSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DownloadedItem2 = {};
|
||||||
|
|
||||||
export const processesAtom = atom<JobStatus[]>([]);
|
export const processesAtom = atom<JobStatus[]>([]);
|
||||||
|
|
||||||
function onAppStateChange(status: AppStateStatus) {
|
function onAppStateChange(status: AppStateStatus) {
|
||||||
@@ -78,8 +74,6 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||||
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const authHeader = useMemo(() => {
|
const authHeader = useMemo(() => {
|
||||||
return api?.accessToken;
|
return api?.accessToken;
|
||||||
}, [api]);
|
}, [api]);
|
||||||
@@ -514,8 +508,10 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
if (downloadedItems) {
|
if (downloadedItems) {
|
||||||
let items = JSON.parse(downloadedItems) as DownloadedItem[];
|
let items: { [key: string]: BaseItemDto } = downloadedItems
|
||||||
items = items.filter((item) => item.item.Id !== id);
|
? JSON.parse(downloadedItems)
|
||||||
|
: {};
|
||||||
|
delete items[id];
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
storage.set("downloadedItems", JSON.stringify(items));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,7 +530,9 @@ function useDownloadProvider() {
|
|||||||
if (i.Id) return deleteFile(i.Id);
|
if (i.Id) return deleteFile(i.Id);
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
).then(() => successHapticFeedback());
|
).then(() =>
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanCacheDirectory = async () => {
|
const cleanCacheDirectory = async () => {
|
||||||
@@ -586,7 +584,7 @@ function useDownloadProvider() {
|
|||||||
const appSizeUsage = useMemo(async () => {
|
const appSizeUsage = useMemo(async () => {
|
||||||
const sizes: number[] =
|
const sizes: number[] =
|
||||||
downloadedFiles?.map((d) => {
|
downloadedFiles?.map((d) => {
|
||||||
return getDownloadedItemSize(d.item.Id!!);
|
return d.fileSize;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
await forEveryDocumentDirFile(
|
await forEveryDocumentDirFile(
|
||||||
@@ -608,8 +606,10 @@ function useDownloadProvider() {
|
|||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
if (downloadedItems) {
|
if (downloadedItems) {
|
||||||
const items: DownloadedItem[] = JSON.parse(downloadedItems);
|
const items: { [key: string]: BaseItemDto } = downloadedItems
|
||||||
const item = items.find((i) => i.item.Id === itemId);
|
? JSON.parse(downloadedItems)
|
||||||
|
: {};
|
||||||
|
const item = items[itemId] as DownloadedItem;
|
||||||
return item || null;
|
return item || null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -623,7 +623,7 @@ function useDownloadProvider() {
|
|||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
if (downloadedItems) {
|
if (downloadedItems) {
|
||||||
return JSON.parse(downloadedItems) as DownloadedItem[];
|
return Object.values(JSON.parse(downloadedItems)) as DownloadedItem[];
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -636,11 +636,11 @@ function useDownloadProvider() {
|
|||||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
let items: DownloadedItem[] = downloadedItems
|
const items: { [key: string]: BaseItemDto } = downloadedItems
|
||||||
? JSON.parse(downloadedItems)
|
? JSON.parse(downloadedItems)
|
||||||
: [];
|
: {};
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
const chosenItem = items[item.Id!] || {};
|
||||||
|
|
||||||
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
|
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
|
||||||
|
|
||||||
@@ -651,12 +651,6 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const newItem = { item, mediaSource: data.mediaSource };
|
const newItem = { item, mediaSource: data.mediaSource };
|
||||||
|
|
||||||
if (existingItemIndex !== -1) {
|
|
||||||
items[existingItemIndex] = newItem;
|
|
||||||
} else {
|
|
||||||
items.push(newItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||||
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
storage.set("downloadedItems", JSON.stringify(items));
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.24.0" },
|
clientInfo: { name: "Streamyfin", version: "0.23.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -92,7 +92,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.24.0"`,
|
}, DeviceId="${deviceId}", Version="0.23.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
[[ -z $(git status --porcelain) ]] &&
|
|
||||||
git checkout master &&
|
|
||||||
git pull --ff-only &&
|
|
||||||
git checkout develop &&
|
|
||||||
git merge master &&
|
|
||||||
git push --follow-tags &&
|
|
||||||
git checkout master &&
|
|
||||||
git merge develop --ff-only &&
|
|
||||||
git push &&
|
|
||||||
git checkout develop ||
|
|
||||||
(echo "Error: Failed to merge" && exit 1)
|
|
||||||
73
scripts/reset-project.js
Executable file
73
scripts/reset-project.js
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This script is used to reset the project to a blank state.
|
||||||
|
* It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||||
|
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const oldDirPath = path.join(root, 'app');
|
||||||
|
const newDirPath = path.join(root, 'app-example');
|
||||||
|
const newAppDirPath = path.join(root, 'app');
|
||||||
|
|
||||||
|
const indexContent = `import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutContent = `import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.rename(oldDirPath, newDirPath, (error) => {
|
||||||
|
if (error) {
|
||||||
|
return console.error(`Error renaming directory: ${error}`);
|
||||||
|
}
|
||||||
|
console.log('/app moved to /app-example.');
|
||||||
|
|
||||||
|
fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
|
||||||
|
if (error) {
|
||||||
|
return console.error(`Error creating new app directory: ${error}`);
|
||||||
|
}
|
||||||
|
console.log('New /app directory created.');
|
||||||
|
|
||||||
|
const indexPath = path.join(newAppDirPath, 'index.tsx');
|
||||||
|
fs.writeFile(indexPath, indexContent, (error) => {
|
||||||
|
if (error) {
|
||||||
|
return console.error(`Error creating index.tsx: ${error}`);
|
||||||
|
}
|
||||||
|
console.log('app/index.tsx created.');
|
||||||
|
|
||||||
|
const layoutPath = path.join(newAppDirPath, '_layout.tsx');
|
||||||
|
fs.writeFile(layoutPath, layoutContent, (error) => {
|
||||||
|
if (error) {
|
||||||
|
return console.error(`Error creating _layout.tsx: ${error}`);
|
||||||
|
}
|
||||||
|
console.log('app/_layout.tsx created.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
0
svenska_kyrkan.sql
Normal file
0
svenska_kyrkan.sql
Normal file
@@ -1,52 +0,0 @@
|
|||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import {
|
|
||||||
MediaRequestStatus,
|
|
||||||
MediaStatus,
|
|
||||||
} from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import {
|
|
||||||
hasPermission,
|
|
||||||
Permission,
|
|
||||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import MediaRequest from "../jellyseerr/server/entity/MediaRequest";
|
|
||||||
import { MovieDetails } from "../jellyseerr/server/models/Movie";
|
|
||||||
import { TvDetails } from "../jellyseerr/server/models/Tv";
|
|
||||||
|
|
||||||
export const useJellyseerrCanRequest = (
|
|
||||||
item?: MovieResult | TvResult | MovieDetails | TvDetails
|
|
||||||
) => {
|
|
||||||
const { jellyseerrUser } = useJellyseerr();
|
|
||||||
|
|
||||||
const canRequest = useMemo(() => {
|
|
||||||
if (!jellyseerrUser || !item) return false;
|
|
||||||
|
|
||||||
const canNotRequest =
|
|
||||||
item?.mediaInfo?.requests?.some(
|
|
||||||
(r: MediaRequest) =>
|
|
||||||
r.status == MediaRequestStatus.PENDING ||
|
|
||||||
r.status == MediaRequestStatus.APPROVED
|
|
||||||
) ||
|
|
||||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
|
||||||
item.mediaInfo?.status === MediaStatus.BLACKLISTED ||
|
|
||||||
item.mediaInfo?.status === MediaStatus.PENDING ||
|
|
||||||
item.mediaInfo?.status === MediaStatus.PROCESSING;
|
|
||||||
|
|
||||||
if (canNotRequest) return false;
|
|
||||||
|
|
||||||
const userHasPermission = hasPermission(
|
|
||||||
[
|
|
||||||
Permission.REQUEST,
|
|
||||||
item?.mediaInfo?.mediaType
|
|
||||||
? Permission.REQUEST_MOVIE
|
|
||||||
: Permission.REQUEST_TV,
|
|
||||||
],
|
|
||||||
jellyseerrUser.permissions,
|
|
||||||
{ type: "or" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return userHasPermission && !canNotRequest;
|
|
||||||
}, [item, jellyseerrUser]);
|
|
||||||
|
|
||||||
return canRequest;
|
|
||||||
};
|
|
||||||
@@ -84,12 +84,10 @@ export type Settings = {
|
|||||||
downloadMethod: "optimized" | "remux";
|
downloadMethod: "optimized" | "remux";
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
showCustomMenuLinks: boolean;
|
showCustomMenuLinks: boolean;
|
||||||
disableHapticFeedback: boolean;
|
|
||||||
subtitleSize: number;
|
subtitleSize: number;
|
||||||
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
||||||
safeAreaInControlsEnabled: boolean;
|
safeAreaInControlsEnabled: boolean;
|
||||||
jellyseerrServerUrl?: string;
|
jellyseerrServerUrl?: string;
|
||||||
hiddenLibraries?: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Settings => {
|
const loadSettings = (): Settings => {
|
||||||
@@ -124,12 +122,10 @@ const loadSettings = (): Settings => {
|
|||||||
downloadMethod: "remux",
|
downloadMethod: "remux",
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
showCustomMenuLinks: false,
|
showCustomMenuLinks: false,
|
||||||
disableHapticFeedback: false,
|
|
||||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||||
remuxConcurrentLimit: 1,
|
remuxConcurrentLimit: 1,
|
||||||
safeAreaInControlsEnabled: true,
|
safeAreaInControlsEnabled: true,
|
||||||
jellyseerrServerUrl: undefined,
|
jellyseerrServerUrl: undefined,
|
||||||
hiddenLibraries: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { MMKV } from "react-native-mmkv";
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
export const storage = new MMKV();
|
const storage = new MMKV();
|
||||||
|
|
||||||
|
const saveItemMapping = (itemId: string | undefined, fileName: string) => {
|
||||||
|
if (!itemId) return;
|
||||||
|
storage.set(itemId, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilePathFromItemId = (itemId: string): string | undefined => {
|
||||||
|
return storage.getString(itemId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatItemName = (item: BaseItemDto) => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
const formattedParentIndexNumber = (item.ParentIndexNumber ?? 0)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const formattedIndexNumber = (item.IndexNumber ?? 0)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
|
||||||
|
const formattedString = `S${formattedParentIndexNumber}E${formattedIndexNumber}`;
|
||||||
|
return `${item.SeriesName} - ${formattedString} - ${item.Name}`;
|
||||||
|
}
|
||||||
|
return item.Name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { saveItemMapping, getFilePathFromItemId, storage, formatItemName };
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { useFocusEffect } from "@react-navigation/core";
|
|
||||||
import {
|
|
||||||
QueryKey,
|
|
||||||
useQuery,
|
|
||||||
UseQueryOptions,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
export function useReactNavigationQuery<
|
|
||||||
TQueryFnData = unknown,
|
|
||||||
TError = unknown,
|
|
||||||
TData = TQueryFnData,
|
|
||||||
TQueryKey extends QueryKey = QueryKey
|
|
||||||
>(
|
|
||||||
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
|
||||||
): UseQueryResult<TData, TError> {
|
|
||||||
const useQueryReturn = useQuery(options);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (
|
|
||||||
((options.refetchOnWindowFocus && useQueryReturn.isStale) ||
|
|
||||||
options.refetchOnWindowFocus === "always") &&
|
|
||||||
options.enabled !== false
|
|
||||||
)
|
|
||||||
useQueryReturn.refetch();
|
|
||||||
}, [options.enabled, options.refetchOnWindowFocus])
|
|
||||||
);
|
|
||||||
|
|
||||||
return useQueryReturn;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user