Compare commits

...

139 Commits

Author SHA1 Message Date
Fredrik Burmester
b4d9552401 chore 2025-01-23 13:37:41 +01:00
Fredrik Burmester
c74336a1a1 chore 2025-01-23 13:16:55 +01:00
Fredrik Burmester
17257ece85 chore 2025-01-23 11:38:42 +01:00
Fredrik Burmester
d5c634b74b Revert "Merge branch 'develop' into chore/expo-52"
This reverts commit 933f3f2f7c, reversing
changes made to f92fee4158.
2025-01-23 11:34:22 +01:00
Fredrik Burmester
933f3f2f7c Merge branch 'develop' into chore/expo-52 2025-01-23 11:30:02 +01:00
Fredrik Burmester
252fc4387b chore 2025-01-23 11:29:35 +01:00
Fredrik Burmester
3e299e2136 fix: early return causing crash 2025-01-23 10:07:21 +01:00
Fredrik Burmester
01cab2277e Merge pull request #451 from RodoMa92/add_self_signed_support
[android] Trust android local CA store for self signed certificates
2025-01-23 10:02:16 +01:00
Fredrik Burmester
e4f4e861e0 Merge pull request #340 from simoncaron/feat/i18n
Implement translation with i18next
2025-01-23 10:01:13 +01:00
Marco Rodolfi
4d665013f0 [android] Trust android local CA store for self signed certificates 2025-01-22 20:08:20 +01:00
sarendsen
9aa4ea4a2e refactor: home section lists 2025-01-22 07:27:08 +01:00
sarendsen
93ae03f55c fix #446 2025-01-20 10:51:39 +01:00
Fredrik Burmester
b311ac98a7 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-17 07:43:23 +01:00
Fredrik Burmester
83d425b2fb chore 2025-01-17 07:43:04 +01:00
Simon Caron
007fbdd0a3 Merge branch 'develop' into feat/i18n 2025-01-16 20:38:00 -05:00
Simon Caron
37df999db5 Merge pull request #1 from Gauvino/fix-typo
fix(i18n): missing typo and comma
2025-01-16 20:35:46 -05:00
sarendsen
72b9675df4 feat: Implement nextup for custom home 2025-01-16 10:36:20 +01:00
lostb1t
7a30a63335 Update README.md 2025-01-15 09:13:03 +01:00
sarendsen
0ff0fab3f4 fix: fix horizontal shows 2025-01-15 00:47:10 +01:00
Fredrik Burmester
d9d9b0ee00 Merge pull request #430 from streamyfin/feat/refreshsettings
feat: Refresh remote settings
2025-01-14 16:29:00 +01:00
Uruk
fdaa69a787 fix(i18n): missing typo and comma 2025-01-14 13:51:43 +01:00
sarendsen
ed5403e597 wip 2025-01-14 10:37:20 +01:00
sarendsen
e6f290b85f wip 2025-01-14 10:35:21 +01:00
sarendsen
aa20d9c701 wip 2025-01-14 10:31:16 +01:00
sarendsen
e7128afb32 wip 2025-01-14 09:48:17 +01:00
sarendsen
a24b126539 wip 2025-01-14 09:24:31 +01:00
sarendsen
e1fe20db86 wip 2025-01-14 07:56:32 +01:00
Simon Caron
cd9f6aa8bd update submodule 2025-01-14 00:06:14 -05:00
Simon Caron
747bd1b416 Merge branch 'develop' into feat/i18n 2025-01-13 22:35:05 -05:00
Simon Caron
364ce46fe5 Screen Orientation Enum + Subtitle Mode 2025-01-13 22:30:57 -05:00
Simon Caron
5703279b46 Merge develop 2025-01-13 21:18:37 -05:00
lostb1t
4022ccb213 feat: Custom homescreen support (#424) 2025-01-13 19:48:19 +01:00
Fredrik Burmester
3a836462f5 Merge pull request #422 from simoncaron/feat/hide-log-page-title
fix: Remove Page Path from Log Page Header
2025-01-13 17:58:39 +01:00
herrrta
8a5f24002f fix: unauthorized plugin access & null default values 2025-01-13 08:30:11 -05:00
retardgerman
c30f9860ee fix: fixed syntax errors 2025-01-13 12:31:23 +01:00
sarendsen
94c170e3d2 chore: some linting 2025-01-13 10:32:03 +01:00
Simon Caron
cd8aba32d8 Jellyseerr 2025-01-13 00:03:41 -05:00
Simon Caron
15f3ddf612 fix: Remove Page Path from Log Page 2025-01-12 23:00:12 -05:00
Simon Caron
90f20f6e46 Shorter messages 2025-01-12 21:34:08 -05:00
Simon Caron
ea1f45bbaf More settings + language component spacing 2025-01-12 21:30:57 -05:00
Simon Caron
7e62c9bc9a Merge branch 'develop' into feat/i18n 2025-01-12 19:49:58 -05:00
herrrta
23f9e9dfae fix: Override default settings with plugin unlocked default settings
- This sets the defaults on login and allows users to still change them
2025-01-12 19:24:01 -05:00
Simon Caron
580e12b605 Alert 2025-01-12 19:04:51 -05:00
Fredrik Burmester
ff4c5f28af chore 2025-01-12 14:11:09 +01:00
Fredrik Burmester
1b931ea348 Merge pull request #419 from streamyfin/fix/remove-music
fix: remove everything related to music
2025-01-12 14:07:59 +01:00
Fredrik Burmester
49c0437f81 fix: change opacity on press 2025-01-12 14:04:12 +01:00
Fredrik Burmester
d81ae94ce8 fix: add version to issue template 2025-01-12 13:41:33 +01:00
Fredrik Burmester
7c77c70024 chore: remove everything related to music 2025-01-12 13:40:01 +01:00
retardgerman
b28c4a56f3 fix: add new Releases to dropdown 2025-01-12 13:39:43 +01:00
Fredrik Burmester
2495a318eb Merge pull request #394 from Ryan0204/enhancement/autohidecontrol
enhancement: auto hide control after 5 seconds
2025-01-12 10:16:55 +01:00
Fredrik Burmester
7832ea4d0a chore: deps 2025-01-12 10:10:18 +01:00
Fredrik Burmester
4a0a51ef1d chore: refactor 2025-01-12 10:07:49 +01:00
Fredrik Burmester
8cc551d906 Merge pull request #416 from streamyfin/feat/server-discovery
feat: server discovery during login
2025-01-12 09:37:33 +01:00
Fredrik Burmester
c8da365a00 fix: issues listed in pr 2025-01-12 09:36:23 +01:00
Fredrik Burmester
74b7cbc530 Merge pull request #417 from whoopsi-daisy/patch-1
Update README.md
2025-01-12 09:33:38 +01:00
𝐂𝐡𝐫𝐢𝐬
a14063a736 Update README.md
Adjusted the Jellyseerr screenshot height to match the others and corrected a typo, along with rephrasing a sentence for clarity
2025-01-12 00:52:17 +08:00
Fredrik Burmester
a3307a90a3 feat: server discovery during login 2025-01-11 11:21:36 +01:00
Fredrik Burmester
a2145fd7e8 chore: update deps 2025-01-11 10:20:20 +01:00
Fredrik Burmester
cab5e4d980 chore: rename var 2025-01-11 10:10:00 +01:00
Fredrik Burmester
ab603e6997 feat: add centralised plugin info 2025-01-11 10:09:53 +01:00
ryan0204
957348fe19 prevent opening control when user swipe on screen 2025-01-11 16:41:41 +08:00
herrrta
444bd040b0 Merge pull request #402 from streamyfin/feat/401
Streamyfin Plugin App Management solution
2025-01-11 00:20:35 -05:00
herrrta
d0ae63235d feat: [StreamyfinPlugin] Library Options settings 2025-01-11 00:16:26 -05:00
herrrta
1727125ea7 feat: [StreamyfinPlugin] Popular Plugin settings 2025-01-11 00:16:25 -05:00
herrrta
dc498d62d8 feat: [StreamyfinPlugin] Other settings 2025-01-11 00:16:25 -05:00
herrrta
455bf08213 feat: [StreamyfinPlugin] Subtitle Toggles settings
- Used stepper & dropdown components to simplify page
2025-01-11 00:16:20 -05:00
herrrta
0f974ef2a3 feat: [StreamyfinPlugin] Audio Toggles settings 2025-01-10 23:26:53 -05:00
herrrta
2d9aaccfe0 feat: [StreamyfinPlugin] Media Toggles settings 2025-01-10 23:26:44 -05:00
herrrta
2c6823eb53 feat: [StreamyfinPlugin] Jellyseerr, Search Engine, & Download settings
- Added DisabledSetting.tsx component
- Added DownloadMethod enum
- cleanup
2025-01-10 23:26:32 -05:00
herrrta
9dfcc01f17 chore 2025-01-10 20:39:32 -05:00
Fredrik Burmester
38aad9610b Merge branch 'feat/401' of https://github.com/streamyfin/streamyfin into feat/401 2025-01-09 15:49:02 +01:00
herrrta
54af64abef api augmentations & added streamyfin plugin id 2025-01-09 09:35:24 -05:00
herrrta
e1720a00da initial changes 2025-01-09 09:33:55 -05:00
herrrta
882d0ea188 api augmentations & added streamyfin plugin id 2025-01-09 08:51:53 -05:00
Fredrik Burmester
f3b539232f Merge pull request #403 from streamyfin/feature/mediasourcenames
Feature: Remove duplicate names from media sources
2025-01-09 11:33:06 +01:00
sarendsen
33ea657a5c Filter out duplicate names in media sources 2025-01-09 10:13:57 +01:00
herrrta
75820adcbc initial changes 2025-01-08 21:52:31 -05:00
herrrta
76cdb2b3f8 fix cast npe 2025-01-08 17:31:39 -05:00
Fredrik Burmester
0a2ea33635 Merge pull request #397 from topiga/master 2025-01-08 18:50:02 +01:00
Théo FORTIN
aad6093852 Added 1 Mb/s as bitrate 2025-01-08 15:22:11 +01:00
herrrta
c553cff9d1 Added clean script 2025-01-08 08:05:06 -05:00
herrrta
dcd458bd3d [Jellyseerr] "Currently Streaming On" misaligned text
fixes #392
2025-01-08 08:04:48 -05:00
Fredrik Burmester
05dc61d17d Merge pull request #395 from streamyfin/feat/331
[Jellyseerr] Show media configuration for admins
2025-01-08 11:40:07 +01:00
Fredrik Burmester
e4de11127f chore 2025-01-08 11:39:50 +01:00
herrrta
2dc49735f4 [Jellyseerr] Show media configuration for admins
implements #331
2025-01-07 23:53:10 -05:00
ryan0204
0ebacd4bd3 Auto hide control after 5 seconds 2025-01-08 11:29:49 +08:00
Simon Caron
14c8c1aaed Fix some missing fields 2025-01-07 22:26:09 -05:00
Simon Caron
2da774272d Merge branch 'develop' into feat/i18n 2025-01-07 20:38:59 -05:00
Fredrik Burmester
ef42207174 Merge pull request #383 from Ryan0204/master
Change ScreenOrientation to landscape right by default and added toggleSafeArea for all videos
2025-01-07 11:01:43 +01:00
Fredrik Burmester
efa5638b12 fix: remove tab sidebar 2025-01-07 10:59:21 +01:00
Fredrik Burmester
c63cea891d chore: remove imports 2025-01-07 10:27:56 +01:00
Fredrik Burmester
4e80f58823 fix: backdrop not filling screen 2025-01-07 10:27:30 +01:00
ryan0204
cfe39d504c Rotate ScreenOrientation back on exit player 2025-01-07 14:13:22 +08:00
herrrta
cf43d1a657 cleanup 2025-01-06 20:56:59 -05:00
herrrta
cbe3b18226 fix enter animation 2025-01-06 20:55:50 -05:00
herrrta
b637a0f7d2 use JellyseerrPoster component 2025-01-06 20:55:48 -05:00
Fredrik Burmester
a0ce7cc6d0 chore 2025-01-06 22:58:11 +01:00
Fredrik Burmester
a640df30bc chore 2025-01-06 22:32:27 +01:00
Fredrik Burmester
062e6e6c23 chore 2025-01-06 22:21:27 +01:00
Fredrik Burmester
d709e3b13e Merge pull request #389 from streamyfin/feat/326
[Jellyseerr] Show genre/studio/network discover sliders
2025-01-06 20:57:10 +01:00
herrrta
b232bebd73 [Jellyseerr] Show genre/studio/network discover sliders
implements #326
2025-01-06 14:25:14 -05:00
Fredrik Burmester
90ef8ef6f9 feat: fade in images 2025-01-06 17:38:59 +01:00
Fredrik Burmester
0df6b8e2a0 chore 2025-01-06 17:33:49 +01:00
Fredrik Burmester
f48b26076d feat: loading skeleton for search (including jellyseerr) 2025-01-06 17:33:27 +01:00
ryan0204
c86a8438e5 Bring back toggleSafeArea button for all videos 2025-01-06 23:15:00 +08:00
Ryan
faa2baae68 Merge branch 'streamyfin:master' into master 2025-01-06 20:28:12 +08:00
ryan0204
ed42371353 Change ScreenOrientation to landscape right by default 2025-01-06 20:27:34 +08:00
Simon Caron
480abb216d fixes 2025-01-05 16:07:55 -05:00
Simon Caron
249109a94e livetv 2025-01-05 16:03:19 -05:00
Simon Caron
eb7fa93f9b remove dupe 2025-01-05 15:26:48 -05:00
Simon Caron
e8fd322d30 Merge branch 'master' into feat/i18n 2025-01-05 15:06:44 -05:00
Simon Caron
53ea1cc899 More Translations 2025-01-04 16:41:54 -05:00
Simon Caron
459ca3245b Rename card field 2025-01-04 15:39:04 -05:00
Simon Caron
0d1fb87284 Fix Language Selector Setting Component 2025-01-04 15:26:24 -05:00
Simon Caron
495742c52c Merge branch 'master' into feat/i18n 2025-01-04 14:57:45 -05:00
Simon Caron
894305e126 Item Card Fields 2025-01-04 14:49:56 -05:00
Simon Caron
ed993d07ce Types 2025-01-03 16:33:51 -05:00
Simon Caron
dc9008f31c Merge branch 'master' into feat/i18n 2025-01-03 15:23:17 -05:00
Fredrik Burmester
f92fee4158 wip 2025-01-03 11:42:18 +01:00
Simon Caron
e23387a384 Library headers, filters and favorites 2025-01-01 21:57:46 -05:00
Simon Caron
bb141cad57 Merge branch 'master' into feat/i18n 2025-01-01 21:32:24 -05:00
Simon Caron
e833b4bc68 Alert and Toasts 2025-01-01 21:31:04 -05:00
Simon Caron
34fc26ed18 Quick connect alerts 2025-01-01 20:29:39 -05:00
Fredrik Burmester
40b8410390 feat: enable manually setting language in settings 2025-01-01 11:25:02 +01:00
Simon Caron
723233381c Settings Fields V 2024-12-31 16:09:12 -05:00
Simon Caron
602de34824 Settings fields 2024-12-31 15:31:36 -05:00
Simon Caron
9b1f2a98e5 Update translation key casing to snake_case 2024-12-31 14:43:40 -05:00
Simon Caron
946de97580 Remove LanguageSwitcher 2024-12-31 14:39:04 -05:00
Simon Caron
f2eadabf6a bump libs versions 2024-12-31 13:52:58 -05:00
Simon Caron
373d83a0d5 Basic downloads stack translation 2024-12-31 13:34:32 -05:00
Simon Caron
2c0ba18b49 Clean up const declarations 2024-12-31 13:10:46 -05:00
Simon Caron
3e8e8e1163 Merge branch 'master' into feat/i18n 2024-12-31 12:24:28 -05:00
Simon Caron
fe9c73a8f0 Library Translation 2024-12-30 21:52:34 -05:00
Simon Caron
4f62391027 Add fr, search translation, fix login title 2024-12-30 21:38:42 -05:00
Simon Caron
53b5fdda87 fix import 2024-12-30 21:13:52 -05:00
Simon Caron
c0b71eb73d Revert login message 2024-12-30 21:03:02 -05:00
Simon Caron
9b4590c876 Update Current Translated Messages with UI Changes 2024-12-30 20:06:56 -05:00
Simon Caron
4b18bad3bc Merge branch 'master' into feat/i18n 2024-12-30 16:45:41 -05:00
Fredrik Burmester
752cb1cdc6 wip 2024-08-18 17:10:31 +02:00
70 changed files with 636 additions and 2014 deletions

View File

@@ -4,7 +4,9 @@ title: "[Bug]: "
labels:
- ["❌ bug"]
projects:
- ["streamyfin/3"]
- ["fredrikburmester/5"]
assignees:
- fredrikburmester
body:
- type: textarea
@@ -43,7 +45,7 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.24.0
- 0.23.0
- 0.22.0
- 0.21.0
- older

View File

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

View File

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

View File

@@ -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/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=150 src="./assets/images/jellyseerr.PNG"/>
</div>
## 🌟 Features
- 🚀 **Skip Intro / Credits Support**
- 🚀 **Skp intro / credits support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.24.0",
"version": "0.23.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 50,
"versionCode": 49,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -111,8 +111,7 @@
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withGoogleCastActivity.js"]
["./plugins/withChangeNativeAndroidTextToWhite.js"]
],
"experiments": {
"typedRoutes": true
@@ -131,6 +130,7 @@
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"newArchEnabled": false
}
}

View File

@@ -77,20 +77,6 @@ export default function IndexLayout() {
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]) => (
<Stack.Screen key={name} name={name} options={options} />
))}

View File

@@ -23,7 +23,7 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
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 { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -116,7 +116,7 @@ export default function index() {
}, []);
const {
data,
data: userViews,
isError: e1,
isLoading: l1,
} = useQuery({
@@ -136,11 +136,6 @@ export default function index() {
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const {
data: mediaListCollections,
isError: e2,

View File

@@ -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>
);
}

View File

@@ -13,22 +13,20 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import { useNavigation, useRouter } from "expo-router";
import { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => {
clearLogs();
successHapticFeedback();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const navigation = useNavigation();
@@ -68,22 +66,6 @@ export default function settings() {
<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">
<ListGroup title={"Logs"}>
<ListItem

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,33 +1,22 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useRef, useState } from "react";
import { useLocalSearchParams } from "expo-router";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { Text } from "@/components/common/Text";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Image } from "expo-image";
import { TouchableOpacity, View } from "react-native";
import { TouchableOpacity, View} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewText } from "@/components/OverviewText";
import { GenreTags } from "@/components/GenreTags";
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Button } from "@/components/Button";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetModal, BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
@@ -38,24 +27,24 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
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 insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const {
mediaTitle,
releaseYear,
canRequest: canRequestString,
posterSrc,
...result
} = params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const navigation = useNavigation();
const canRequest = canRequestString === "true";
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
@@ -66,7 +55,7 @@ const Page: React.FC = () => {
data: details,
isFetching,
isLoading,
refetch,
refetch
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
@@ -83,8 +72,6 @@ const Page: React.FC = () => {
},
});
const canRequest = useJellyseerrCanRequest(details);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -108,32 +95,21 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(async () => {
requestMedia(
mediaTitle,
{
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
},
refetch
);
}, [details, result, requestMedia]);
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<ItemActions item={details} />
</TouchableOpacity>
),
});
}
}, [details]);
const request = useCallback(
async () => {
requestMedia(mediaTitle, {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
},
refetch
)
},
[details, result, requestMedia]
);
return (
<View
@@ -157,10 +133,7 @@ const Page: React.FC = () => {
height: "100%",
}}
source={{
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces"
),
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
}}
/>
) : (
@@ -209,9 +182,7 @@ const Page: React.FC = () => {
<View className="mb-4">
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button loading={true} disabled={true} color="purple"></Button>
) : canRequest ? (
{canRequest ? (
<Button color="purple" onPress={request}>
Request
</Button>
@@ -242,11 +213,6 @@ const Page: React.FC = () => {
refetch={refetch}
/>
)}
<DetailFacts
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
details={details}
/>
<Cast details={details} />
</View>
</View>
</ParallaxScrollView>
@@ -313,11 +279,13 @@ const Page: React.FC = () => {
</DropdownMenu.Root>
</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
multiline
maxLength={254}
style={{ color: "white" }}
style={{color: "white"}}
clearButtonMode="always"
placeholder="(optional) Describe the issue..."
placeholderTextColor="#9CA3AF"

View File

@@ -10,7 +10,7 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -23,20 +23,20 @@ export default function index() {
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
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(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
@@ -63,7 +63,7 @@ export default function index() {
</View>
);
if (!libraries)
if (!data)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No libraries found</Text>
@@ -81,7 +81,7 @@ export default function index() {
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={libraries}
data={data}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>

View File

@@ -36,7 +36,6 @@ export default function SearchLayout() {
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/[personId]" options={commonScreenOptions} />
</Stack>
);
}

View File

@@ -31,18 +31,12 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Tag } from "@/components/GenreTags";
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
import { sortBy } from "lodash";
import PersonPoster from "@/components/jellyseerr/PersonPoster";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
type SearchType = "Library" | "Discover";
@@ -155,8 +149,8 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "results", debouncedSearch],
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
queryKey: ["search", "jellyseerrResults", debouncedSearch],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(debouncedSearch).toString(),
@@ -172,15 +166,14 @@ export default function search() {
debouncedSearch.length > 0,
});
const { data: jellyseerrDiscoverSettings, isFetching: j2 } =
useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length == 0,
});
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length == 0,
});
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
() =>
@@ -198,14 +191,6 @@ export default function search() {
[jellyseerrResults]
);
const jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === "person"
) as PersonResult[],
[jellyseerrResults]
);
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -315,7 +300,7 @@ export default function search() {
paddingRight: insets.right,
}}
>
<View className="flex flex-col">
<View className="flex flex-col pt-2">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
@@ -501,19 +486,6 @@ export default function search() {
<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}
/>
)}
/>
</>
)}

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useRef } from "react";
import React from "react";
import { Platform } from "react-native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
@@ -13,13 +13,12 @@ const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
@@ -30,23 +29,6 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() {
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 (
<>
<SystemBars hidden={false} style="light" />

View File

@@ -27,7 +27,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -68,11 +68,9 @@ export default function page() {
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
const {
@@ -177,7 +175,7 @@ export default function page() {
const togglePlay = useCallback(async () => {
if (!api) return;
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
await videoRef.current?.pause();
@@ -437,6 +435,7 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
}}
>
<VlcPlayerView

View File

@@ -17,7 +17,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
@@ -45,8 +45,6 @@ export default function page() {
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const lightHapticFeedback = useHaptic("light");
const {
itemId,
audioIndex: audioIndexStr,
@@ -126,7 +124,7 @@ export default function page() {
const togglePlay = useCallback(
async (ticks: number) => {
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({

View File

@@ -20,7 +20,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -48,7 +48,6 @@ const Player = () => {
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -59,7 +58,7 @@ const Player = () => {
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
const progress = useSharedValue(0);
@@ -168,7 +167,7 @@ const Player = () => {
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
@@ -388,6 +387,7 @@ const Player = () => {
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
}}
>
{videoSource ? (

View File

@@ -319,7 +319,7 @@ function Layout() {
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{

View File

@@ -2,13 +2,8 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import {
Ionicons,
MaterialCommunityIcons,
MaterialIcons,
} from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
@@ -44,6 +39,7 @@ const Login: React.FC = () => {
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
@@ -81,10 +77,8 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
className="flex flex-row items-center"
>
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">Change server</Text>
<Ionicons name="chevron-back" size={24} color="white" />
</TouchableOpacity>
) : null,
});
@@ -101,9 +95,9 @@ const Login: React.FC = () => {
}
} catch (error) {
if (error instanceof Error) {
Alert.alert("Connection failed", error.message);
setError(error.message);
} else {
Alert.alert("Connection failed", "An unexpected error occurred");
setError("An unexpected error occurred");
}
} finally {
setLoading(false);
@@ -142,8 +136,6 @@ const Login: React.FC = () => {
return url;
}
return undefined;
} catch {
return undefined;
} finally {
setLoadingServerCheck(false);
@@ -197,131 +189,133 @@ const Login: React.FC = () => {
}
};
return (
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
{api?.basePath ? (
<>
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
Log in
<>
{serverName ? (
<>
{" to "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : null}
</>
</Text>
<Text className="text-xs text-neutral-400">
{api.basePath}
</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
<View className="flex flex-row items-center justify-between">
<Button
onPress={handleLogin}
loading={loading}
className="flex-1 mr-2"
>
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 className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
</View>
</>
) : (
<>
<View className="flex flex-col h-full items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Enter the URL to your Jellyfin server
if (api?.basePath) {
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">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
Log in
<>
{serverName ? (
<>
{" to "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : null}
</>
</Text>
<Text className="text-xs text-neutral-400">{api.basePath}</Text>
<Input
aria-label="Server URL"
placeholder="http(s)://your-server.com"
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
}}
<Input
className="mb-2"
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
</View>
<Text className="text-red-600 mb-2">{error}</Text>
</View>
</>
)}
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
<Button
color="black"
onPress={handleQuickConnect}
className="w-full mb-2"
>
Use Quick Connect
</Button>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, 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">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Enter the URL to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
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
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
</View>
</View>
</KeyboardAvoidingView>
</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

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,4 +1,4 @@
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -37,14 +37,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900";
return "bg-neutral-900 border border-neutral-800";
case "transparent":
return "bg-transparent";
}
}, [color]);
const lightHapticFeedback = useHaptic("light");
return (
<TouchableOpacity
className={`
@@ -56,16 +54,14 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
}}
disabled={disabled || loading}
{...props}
>
{loading ? (
<View className="p-0.5">
<Loader />
</View>
<Loader />
) : (
<View
className={`

View File

@@ -1,6 +1,6 @@
// GenreTags.tsx
import React from "react";
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
import {View, ViewProps} from "react-native";
import { Text } from "./common/Text";
interface TagProps {
@@ -8,15 +8,14 @@ interface TagProps {
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,
textClass,
textStyle,
...props
}) => {
return (
<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 File

@@ -32,7 +32,7 @@ import Animated, {
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -64,7 +64,6 @@ export const PlayButton: React.FC<Props> = ({
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
@@ -80,7 +79,7 @@ export const PlayButton: React.FC<Props> = ({
const onPress = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const queryParams = new URLSearchParams({
itemId: item.Id!,

View File

@@ -6,7 +6,7 @@ import {
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
interface Props extends TouchableOpacityProps {
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 fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (hapticFeedback) {
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress?.();
};

View File

@@ -1,10 +0,0 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { ThemedText } from '../ThemedText';
it(`renders correctly`, () => {
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
expect(tree).toMatchSnapshot();
});

View File

@@ -1,24 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Text
style={
[
{
"color": "#11181C",
},
{
"fontSize": 16,
"lineHeight": 24,
},
undefined,
undefined,
undefined,
undefined,
undefined,
]
}
>
Snapshot test!
</Text>
`;

View File

@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
return (
<TextInput
ref={inputRef}
className="p-4 rounded-xl bg-neutral-900"
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}

View File

@@ -1,11 +1,12 @@
import {useRouter, useSegments} from "expo-router";
import React, {PropsWithChildren, useCallback, useMemo} from "react";
import {TouchableOpacity, TouchableOpacityProps} from "react-native";
import * as ContextMenu from "zeego/context-menu";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useRouter, useSegments } from "expo-router";
import React, { PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {
result: MovieResult | TvResult;
@@ -26,78 +27,49 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}) => {
const router = useRouter();
const segments = useSegments();
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = segments[2];
const autoApprove = useMemo(() => {
return jellyseerrUser && hasPermission(
Permission.AUTO_APPROVE,
jellyseerrUser.permissions,
{type: 'or'}
)
}, [jellyseerrApi, jellyseerrUser])
return (
jellyseerrUser &&
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
type: "or",
})
);
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(() =>
const request = useCallback(
() =>
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType: result.mediaType
}
),
mediaType: result.mediaType,
}),
[jellyseerrApi, result]
)
);
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={() => {
// @ts-ignore
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
}}
{...props}
>
{children}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
{canRequest && result.mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key="item-1"
onSelect={() => {
if (autoApprove) {
request()
}
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
},
}}
androidIconName="download"
/>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
<TouchableOpacity
onPress={() => {
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
params: {
...result,
mediaTitle,
releaseYear,
// @ts-expect-error
canRequest,
posterSrc,
},
});
}}
{...props}
>
{children}
</TouchableOpacity>
</>
);
};

View File

@@ -4,11 +4,8 @@ import {
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren, useCallback } from "react";
import { PropsWithChildren } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
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 {
item: BaseItemDto;
@@ -68,33 +65,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
}) => {
const router = useRouter();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed(item);
const from = segments[2];
const showActionSheet = useCallback(() => {
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
const cancelButtonIndex = 2;
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]);
const markAsPlayedStatus = useMarkAsPlayed(item);
if (
from === "(home)" ||
@@ -104,10 +78,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
)
return (
<TouchableOpacity
onLongPress={showActionSheet}
onPress={() => {
const url = itemRouter(item, from);
// @ts-expect-error
// @ts-ignore
router.push(url);
}}
{...props}

View File

@@ -1,5 +1,5 @@
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 { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
@@ -26,7 +26,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
@@ -42,7 +41,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
successHapticFeedback();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);

View File

@@ -3,7 +3,7 @@ import {
useActionSheet,
} from "@expo/react-native-action-sheet";
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 { TouchableOpacity, View } from "react-native";
@@ -28,7 +28,6 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
@@ -44,7 +43,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
successHapticFeedback();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);

View File

@@ -22,7 +22,7 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useRouter, useSegments } from "expo-router";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
interface Props extends ViewProps {}
@@ -128,7 +128,6 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const screenWidth = Dimensions.get("screen").width;
const lightHapticFeedback = useHaptic("light");
const uri = useMemo(() => {
if (!api) return null;
@@ -154,7 +153,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// @ts-ignore
if (url) router.push(url);
}, [item, from]);

View File

@@ -2,6 +2,7 @@ import {useEffect, useState} from "react";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {TouchableOpacity, View, ViewProps} from "react-native";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
interface Props {
mediaStatus?: MediaStatus;
@@ -9,7 +10,7 @@ interface Props {
onPress?: () => void;
}
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -68,4 +69,4 @@ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
)
}
export default JellyseerrStatusIcon;
export default JellyseerrIconStatus;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,47 +1,55 @@
import { View, ViewProps } from "react-native";
import { Image } from "expo-image";
import { Text } from "@/components/common/Text";
import { useMemo } from "react";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {View, ViewProps} from "react-native";
import {Image} from "expo-image";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {Text} from "@/components/common/Text";
import {useEffect, useMemo, useState} from "react";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
interface Props extends ViewProps {
item: MovieResult | TvResult;
}
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
const JellyseerrPoster: React.FC<Props> = ({
item,
...props
}) => {
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
// const imageSource =
const imageSrc = useMemo(
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
const imageSrc = useMemo(() =>
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]
);
const title = useMemo(
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
)
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
const releaseYear = useMemo(() =>
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
[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 (
<TouchableJellyseerrRouter
@@ -49,14 +57,14 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={imageSrc!!}
posterSrc={imageSrc}
>
<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]">
<Image
key={item.id}
id={item.id.toString()}
source={{ uri: imageSrc }}
source={{uri: imageSrc}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
@@ -64,24 +72,21 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
width: "100%",
}}
/>
<JellyseerrStatusIcon
<JellyseerrIconStatus
className="absolute bottom-1 right-1"
showRequestIcon={canRequest}
mediaStatus={item?.mediaInfo?.status}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item?.mediaType}
/>
</View>
<View className="mt-2 flex flex-col">
<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>
</TouchableJellyseerrRouter>
);
};
)
}
export default JellyseerrPoster;
export default JellyseerrPoster;

View File

@@ -1,15 +1,19 @@
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { View } from "react-native";
type PosterProps = {
id?: string | null;
item?: BaseItemDto | BaseItemPerson | null;
url?: string | null;
showProgress?: boolean;
blurhash?: string | null;
};
const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
if (!id && !url)
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
if (!item)
return (
<View
className="border border-neutral-900"
@@ -29,8 +33,8 @@ const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
}
: null
}
key={id}
id={id!!}
key={item.Id}
id={item.Id}
source={
url
? {

View File

@@ -55,7 +55,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
}}
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="text-xs opacity-50">{i.Role}</Text>
</TouchableOpacity>

View File

@@ -29,7 +29,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
className="flex flex-col space-y-2 w-28"
>
<Poster
id={item.id}
item={item}
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
/>
<Text>{item.SeriesName}</Text>

View File

@@ -5,7 +5,7 @@ import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import { orderBy } from "lodash";
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 {
MediaStatus,
@@ -61,7 +61,7 @@ const RenderItem = ({ item, index }: any) => {
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
@@ -246,7 +246,7 @@ const JellyseerrSeasons: React.FC<{
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
?.status === MediaStatus.UNKNOWN;
return (
<JellyseerrStatusIcon
<JellyseerrIconStatus
key={0}
onPress={() => requestSeason(canRequest, season.seasonNumber)}
className={canRequest ? "bg-gray-700/40" : undefined}

View File

@@ -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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import {
Alert,
Linking,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
import { TouchableOpacity, View, ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto | MovieDetails | TvDetails;
item: BaseItemDto;
}
export const ItemActions = ({ item, ...props }: Props) => {
const trailerLink = useMemo(() => {
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
return item.RemoteTrailers[0].Url;
}
const router = useRouter();
if ("relatedVideos" in item) {
return item.relatedVideos?.find((v) => v.type === "Trailer")?.url;
}
return undefined;
}, [item]);
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
const openTrailer = useCallback(async () => {
if (!trailerLink) {
Alert.alert("No trailer available");
return;
}
if (!trailerLink) return;
try {
await Linking.openURL(trailerLink);
} catch (err) {
console.error("Failed to open trailer link:", err);
}
}, [trailerLink]);
const encodedTrailerLink = encodeURIComponent(trailerLink);
router.push(`/trailer/page?url=${encodedTrailerLink}`);
}, [router, trailerLink]);
return (
<View className="" {...props}>

View File

@@ -1,16 +1,17 @@
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { useMutation } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { View } from "react-native";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { useCallback, useRef, useState } from "react";
import { Input } from "../common/Input";
import { ListItem } from "../list/ListItem";
import { Loader } from "../Loader";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { toast } from "sonner-native";
import { useMutation } from "@tanstack/react-query";
import { ListGroup } from "../list/ListGroup";
export const JellyseerrSettings = () => {
const {

View File

@@ -6,7 +6,6 @@ import {
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch";
import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import React, { useEffect } from "react";
@@ -20,7 +19,6 @@ import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const OtherSettings: React.FC = () => {
const router = useRouter();
const [settings, updateSettings] = useSettings();
/********************
@@ -56,7 +54,7 @@ export const OtherSettings: React.FC = () => {
if (!settings) return null;
return (
<ListGroup title="Other" className="">
<ListGroup title="Other" className="mb-4">
<ListItem title="Auto rotate">
<Switch
value={settings.autoRotate}
@@ -180,19 +178,6 @@ export const OtherSettings: React.FC = () => {
}
/>
</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>
);
};

View File

@@ -1,115 +1,59 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useHaptic } from "@/hooks/useHaptic";
import { useAtom } from "jotai";
import React, { useCallback, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native";
import { Button } from "../Button";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Button } from "../Button";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import Constants from "expo-constants";
import Application from "expo-application";
import { ListGroup } from "../list/ListGroup";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
interface Props extends ViewProps {}
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: quickConnectCode,
userId: user?.Id,
});
if (res.status === 200) {
successHapticFeedback();
Alert.alert("Success", "Quick connect authorized");
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();
} else {
errorHapticFeedback();
Alert.alert("Error", "Invalid code");
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
"Enter the quick connect code",
async (text) => {
if (text) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: text,
userId: user?.Id,
});
if (res.status === 200) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Alert.alert("Success", "Quick connect authorized");
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
}
} catch (e) {
errorHapticFeedback();
Alert.alert("Error", "Invalid code");
}
}
}, [api, user, quickConnectCode]);
);
};
return (
<View {...props}>
<ListGroup title={"Quick Connect"}>
<ListItem
onPress={() => bottomSheetModalRef?.current?.present()}
onPress={openQuickConnectAuthCodeInput}
title="Authorize Quick Connect"
textColor="blue"
/>
></ListItem>
</ListGroup>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">
Quick Connect
</Text>
</View>
<View className="flex flex-col space-y-2">
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<BottomSheetTextInput
style={{ color: "white" }}
clearButtonMode="always"
placeholder="Enter the quick connect code..."
placeholderTextColor="#9CA3AF"
value={quickConnectCode}
onChangeText={setQuickConnectCode}
/>
</View>
</View>
<Button
className="mt-auto"
onPress={authorizeQuickConnect}
color="purple"
>
Authorize
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};

View File

@@ -4,7 +4,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import { View } from "react-native";
import * as Progress from "react-native-progress";
import { toast } from "sonner-native";
@@ -13,8 +13,6 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
@@ -31,9 +29,9 @@ export const StorageSettings = () => {
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e) {
errorHapticFeedback();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
toast.error("Error deleting files");
}
};

View File

@@ -29,7 +29,7 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -157,12 +157,10 @@ export const Controls: React.FC<Props> = ({
isVlc
);
const lightHapticFeedback = useHaptic("light");
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -200,7 +198,7 @@ export const Controls: React.FC<Props> = ({
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -328,7 +326,7 @@ export const Controls: React.FC<Props> = ({
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try {
const curr = progress.value;
if (curr !== undefined) {
@@ -346,7 +344,7 @@ export const Controls: React.FC<Props> = ({
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try {
const curr = progress.value;
if (curr !== undefined) {
@@ -363,7 +361,7 @@ export const Controls: React.FC<Props> = ({
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
const memoizedRenderBubble = useCallback(() => {
@@ -442,7 +440,7 @@ export const Controls: React.FC<Props> = ({
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const previousIndexes: previousIndexes = {
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
onPressIn={() => {
toggleControls();
@@ -507,8 +532,6 @@ export const Controls: React.FC<Props> = ({
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
backgroundColor: "black",
opacity: showControls ? 0.5 : 0,
}}
></Pressable>
@@ -518,82 +541,61 @@ export const Controls: React.FC<Props> = ({
position: "absolute",
top: settings?.safeAreaInControlsEnabled ? insets.top : 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,
},
]}
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 && (
<TouchableOpacity
onPress={() => {
switchOnEpisodeMode();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && !offline && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
)}
{nextItem && !offline && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={async () => {
lightHapticFeedback();
router.back();
onPress={() => {
switchOnEpisodeMode();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
</View>
)}
{previousItem && !offline && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
)}
{nextItem && !offline && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View

View File

@@ -118,7 +118,14 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
);
return (
<View>
<View
style={{
position: "absolute",
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">

View File

@@ -22,13 +22,13 @@
}
},
"production": {
"channel": "0.24.0",
"channel": "0.23.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.24.0",
"channel": "0.23.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
import * as Haptics from "expo-haptics";
interface CreditTimestamps {
Introduction: {
@@ -29,7 +29,6 @@ export const useCreditSkipper = (
) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
if (isVlc) {
currentTime = msToSeconds(currentTime);
@@ -80,7 +79,7 @@ export const useCreditSkipper = (
if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try {
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
wrappedSeek(creditTimestamps.Credits.End);
setTimeout(() => {
play();

View File

@@ -6,7 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
// Used only for initial play settings.
// Used only for intial play settings.
const useDefaultPlaySettings = (
item: BaseItemDto,
settings: Settings | null

View File

@@ -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];
};

View File

@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
import { useHaptic } from "./useHaptic";
import * as Haptics from "expo-haptics";
interface IntroTimestamps {
EpisodeId: string;
@@ -33,7 +33,6 @@ export const useIntroSkipper = (
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
if (isVlc) {
@@ -79,7 +78,7 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
wrappedSeek(introTimestamps.IntroEnd);
setTimeout(() => {
play();

View File

@@ -28,11 +28,6 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue";
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import { writeErrorLog } from "@/utils/log";
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 {
query: string;
@@ -60,8 +55,6 @@ export enum Endpoints {
API_V1 = "/api/v1",
SEARCH = "/search",
REQUEST = "/request",
PERSON = "/person",
COMBINED_CREDITS = "/combined_credits",
MOVIE = "/movie",
RATINGS = "/ratings",
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) {
return this.axios
?.get<RTRating>(
@@ -266,20 +238,14 @@ export class JellyseerrApi {
});
}
imageProxy(
path?: string,
tmdbPath: string = "original",
width: number = 1920,
quality: number = 75
) {
return path
? this.axios.defaults.baseURL +
`/_next/image?` +
new URLSearchParams(
`url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}`
).toString()
: this.axios?.defaults.baseURL +
`/images/overseerr_poster_not_found_logo_top.png`;
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
return (
this.axios.defaults.baseURL +
`/_next/image?` +
new URLSearchParams(
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
).toString()
);
}
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
@@ -355,7 +321,6 @@ const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
export const useJellyseerr = () => {
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const [settings, updateSettings] = useSettings();
const queryClient = useQueryClient();
const jellyseerrApi = useMemo(() => {
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
@@ -373,16 +338,12 @@ export const useJellyseerr = () => {
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
await queryClient.invalidateQueries({
queryKey: ["search", "jellyseerr"],
});
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
switch (mediaRequest.status) {
case MediaRequestStatus.PENDING:
case MediaRequestStatus.APPROVED:
toast.success(`Requested ${title}!`);
onSuccess?.();
onSuccess?.()
break;
case MediaRequestStatus.DECLINED:
toast.error(`You don't have permission to request!`);

View File

@@ -3,14 +3,13 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import { useHaptic } from "./useHaptic";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
export const useMarkAsPlayed = (item: BaseItemDto) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const lightHapticFeedback = useHaptic("light");
const invalidateQueries = () => {
const queriesToInvalidate = [
@@ -30,7 +29,7 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
};
const markAsPlayedStatus = async (played: boolean) => {
lightHapticFeedback();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Optimistic update
queryClient.setQueryData(

View File

@@ -4,102 +4,100 @@
"version": "1.0.0",
"scripts": {
"submodule-reload": "git submodule update --init --remote --recursive",
"clean": "echo y | expo prebuild --clean",
"start": "bun run submodule-reload && expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "bun run submodule-reload && expo run:android",
"ios": "bun run submodule-reload && expo run:ios",
"web": "bun run submodule-reload && expo start --web",
"test": "jest --watchAll",
"lint": "expo lint",
"postinstall": "patch-package"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@bottom-tabs/react-navigation": "^0.7.1",
"@bottom-tabs/react-navigation": "0.8.0",
"react-native-bottom-tabs": "0.8.0",
"@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/native": "^7.0.14",
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.5",
"@gorhom/bottom-sheet": "^4.6.4",
"@gorhom/bottom-sheet": "^5.0.6",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "3.1.2",
"@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "^1.1.6",
"@react-navigation/material-top-tabs": "^6.6.14",
"@react-navigation/native": "^6.1.18",
"@shopify/flash-list": "1.6.4",
"@shopify/flash-list": "1.7.1",
"@tanstack/react-query": "^5.59.20",
"@types/lodash": "^4.17.13",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6",
"axios": "^1.7.7",
"expo": "~51.0.39",
"expo-asset": "~10.0.10",
"expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2",
"expo-brightness": "~12.0.1",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.29",
"expo-device": "~6.0.2",
"expo-font": "~12.0.10",
"expo-haptics": "~13.0.1",
"expo-image": "~1.13.0",
"expo-keep-awake": "~13.0.2",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-network": "~6.0.1",
"expo-notifications": "~0.28.19",
"expo-router": "~3.5.24",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "^3.0.7",
"expo-task-manager": "~11.8.2",
"expo-updates": "~0.25.27",
"expo-web-browser": "~13.0.3",
"expo": "^52.0.0",
"expo-asset": "~11.0.2",
"expo-background-fetch": "~13.0.4",
"expo-blur": "~14.0.3",
"expo-brightness": "~13.0.3",
"expo-build-properties": "~0.13.2",
"expo-constants": "~17.0.4",
"expo-dev-client": "~5.0.10",
"expo-device": "~7.0.2",
"expo-font": "~13.0.3",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.4",
"expo-keep-awake": "~14.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.4",
"expo-network": "~7.0.5",
"expo-notifications": "~0.29.12",
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-splash-screen": "~0.29.21",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.7",
"expo-task-manager": "~12.0.4",
"expo-updates": "~0.26.13",
"expo-web-browser": "~14.0.2",
"ffmpeg-kit-react-native": "^6.0.2",
"install": "^0.13.0",
"jotai": "^2.10.1",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.6",
"react-native-awesome-slider": "^2.5.6",
"react-native-bottom-tabs": "0.7.1",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.9.0",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.1",
"react-native-edge-to-edge": "^1.1.3",
"react-native-gesture-handler": "~2.16.1",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^2.5.2",
"react-native-ios-utilities": "4.5.3",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.3.0",
"react-native-pager-view": "6.5.1",
"react-native-progress": "^5.0.1",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated": "~3.16.1",
"react-native-reanimated-carousel": "4.0.0-canary.22",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-tab-view": "^3.5.2",
"react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.7.0",
"react-native-volume-manager": "^1.10.0",
"react-native-web": "~0.19.13",
"react-native-webview": "13.8.6",
"react-native-webview": "13.12.5",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
@@ -110,10 +108,8 @@
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/jest": "^29.5.14",
"@types/react": "~18.2.79",
"@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1",
"jest-expo": "~51.0.4",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "18.2.0",

View File

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

View File

@@ -48,7 +48,7 @@ import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import { FileInfo } from "expo-file-system";
import { useHaptic } from "@/hooks/useHaptic";
import * as Haptics from "expo-haptics";
import * as Application from "expo-application";
export type DownloadedItem = {
@@ -78,8 +78,6 @@ function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const successHapticFeedback = useHaptic("success");
const authHeader = useMemo(() => {
return api?.accessToken;
}, [api]);
@@ -534,7 +532,9 @@ function useDownloadProvider() {
if (i.Id) return deleteFile(i.Id);
return;
})
).then(() => successHapticFeedback());
).then(() =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
);
};
const cleanCacheDirectory = async () => {

View File

@@ -55,7 +55,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.24.0" },
clientInfo: { name: "Streamyfin", version: "0.23.0" },
deviceInfo: {
name: deviceName,
id,
@@ -92,7 +92,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.24.0"`,
}, DeviceId="${deviceId}", Version="0.23.0"`,
};
}, [deviceId]);

View File

@@ -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
View 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
View File

View 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;
};

View File

@@ -84,12 +84,10 @@ export type Settings = {
downloadMethod: "optimized" | "remux";
autoDownload: boolean;
showCustomMenuLinks: boolean;
disableHapticFeedback: boolean;
subtitleSize: number;
remuxConcurrentLimit: 1 | 2 | 3 | 4;
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
};
const loadSettings = (): Settings => {
@@ -124,12 +122,10 @@ const loadSettings = (): Settings => {
downloadMethod: "remux",
autoDownload: false,
showCustomMenuLinks: false,
disableHapticFeedback: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1,
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
};
try {

View File

@@ -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;
}