Compare commits

...

164 Commits

Author SHA1 Message Date
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
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
Fredrik Burmester
24277135a8 Merge branch 'master' into develop 2025-01-06 10:14:27 +01:00
Fredrik Burmester
23d9cd36d1 chore 2025-01-06 10:14:17 +01:00
Fredrik Burmester
b243524a7d chore 2025-01-06 10:13:40 +01:00
Fredrik Burmester
8288682e68 feat: intro screen 2025-01-05 23:28:24 +01:00
Fredrik Burmester
58ec915699 feat: intro page 2025-01-05 22:17:02 +01:00
Fredrik Burmester
cad03a3566 fix: chromecast 2025-01-05 20:59:45 +01:00
Fredrik Burmester
9baa4063bd chore 2025-01-05 16:22:52 +01:00
Fredrik Burmester
41db34ed8e fix: hide libraries from home sections as well 2025-01-05 16:22:32 +01:00
retardgerman
5aba66ce05 fix: add jellyseerr screenshot 2025-01-05 16:09:06 +01:00
retardgerman
79407ccd70 Add files via upload 2025-01-05 16:06:21 +01:00
retardgerman
9a93b3b3bb Delete jellyseerr.PNG 2025-01-05 16:05:51 +01:00
retardgerman
2b846a1aca Add files via upload 2025-01-05 16:01:02 +01:00
Fredrik Burmester
55d61172f4 Merge branch 'master' into develop 2025-01-05 15:53:06 +01:00
Fredrik Burmester
57173a62dc Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-05 15:51:52 +01:00
Fredrik Burmester
78f65be09d Merge branch 'master' into develop 2025-01-05 15:51:33 +01:00
Fredrik Burmester
293a9517a5 Merge pull request #377 from anubhavvs/feature/haptic-feedback-toggle
feat: haptic feedback settings and custom hook
2025-01-05 15:50:57 +01:00
Fredrik Burmester
38b6215046 Merge pull request #376 from eltociear/patch-1
chore: update useDefaultPlaySettings.ts
2025-01-05 15:48:32 +01:00
Fredrik Burmester
fc4a11d916 Merge pull request #378 from streamyfin/feat/hide-libraries
feat: hide libraries from libraries tab
2025-01-05 15:47:29 +01:00
Fredrik Burmester
cf2beb8299 feat: hide libraries 2025-01-05 15:46:44 +01:00
Fredrik Burmester
49d157a95a fix: overlapping buttons with navbar fixes #373 2025-01-05 15:29:39 +01:00
Anubhav Saha
9692c173ae feat: haptic feedback settings and custom hook 2025-01-05 15:22:52 +01:00
Ikko Eltociear Ashimine
a297ac4843 chore: update useDefaultPlaySettings.ts
intial -> initial
2025-01-05 23:08:44 +09:00
Fredrik Burmester
a061f9f480 fix: padding 2025-01-05 12:04:11 +01:00
Fredrik Burmester
0fb6f2fb30 fix: padding 2025-01-05 12:04:07 +01:00
Fredrik Burmester
0773f773ba fix: padding 2025-01-05 12:04:02 +01:00
Fredrik Burmester
39bb3a9370 fix: padding 2025-01-05 12:03:56 +01:00
Fredrik Burmester
b79e534692 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-05 11:56:53 +01:00
Fredrik Burmester
e9336e9a67 feat: new useQuery specifically for react navigation to invalidate !enabled queries on screen re-mount 2025-01-05 11:56:46 +01:00
Fredrik Burmester
adfde1a7cd fix: update request status in poster on search scree 2025-01-05 11:56:16 +01:00
Fredrik Burmester
cab6257fb2 feat: hook for request permissions 2025-01-05 11:55:52 +01:00
Fredrik Burmester
3f0f0090af fix: refactor permissions 2025-01-05 11:55:41 +01:00
Fredrik Burmester
b278632581 fix: height on loading button 2025-01-05 11:55:29 +01:00
Fredrik Burmester
5ee1a9cabb fix: change keys 2025-01-05 11:55:22 +01:00
Fredrik Burmester
2169bea031 fix: add loading and refactor permissions 2025-01-05 11:55:05 +01:00
Fredrik Burmester
95cf252349 Delete svenska_kyrkan.sql 2025-01-05 10:29:59 +01:00
Fredrik Burmester
8470cbe8d5 Delete svenska_kyrkan.sql 2025-01-05 10:29:12 +01:00
Fredrik Burmester
636a27246f chore: deps 2025-01-05 09:51:35 +01:00
Fredrik Burmester
a488c68633 fix: sizing 2025-01-05 09:49:57 +01:00
Fredrik Burmester
7342b7eb92 chore: deps 2025-01-05 09:46:51 +01:00
Fredrik Burmester
8370519758 chore: formatting 2025-01-05 09:46:48 +01:00
Fredrik Burmester
85e21edbf1 fix: padding in person view appearences grid 2025-01-05 09:46:30 +01:00
Fredrik Burmester
8d4115f5a0 fix: app crash on internal yt trailer, link out instead 2025-01-05 09:24:54 +01:00
herrrta
c5d7a6729b Merge pull request #372 from herrrta/feat/327
[Jellyseerr] Add cast/crew results
2025-01-05 02:55:41 -05:00
herrrta
db4046267f [Jellyseerr] Add cast/crew results
implements #327
2025-01-05 02:53:41 -05:00
retardgerman
1e869a2c2f fix: auto add feature requests to roadmap 2025-01-05 08:28:32 +01:00
retardgerman
b6502c042a fix: removed assignees and modified link to roadmap 2025-01-05 08:16:04 +01:00
herrrta
b506871c46 Merge pull request #371 from herrrta/feat/328
[Jellyseerr] Add external links to trailers
2025-01-04 21:29:10 -05:00
herrrta
734678b1d5 [Jellyseerr] Add external links to trailers
implements #328
2025-01-04 21:28:45 -05:00
herrrta
68e98bbb94 Merge pull request #370 from herrrta/feat/325
[Jellyseerr] Show facts about media result
2025-01-04 21:06:32 -05:00
herrrta
d84ed558f3 [Jellyseerr] Show facts about media result
implements #325
2025-01-04 21:04:12 -05:00
herrrta
ad39e8e10a Merge pull request #369 from herrrta/fix/lock-ios-util-version
Fix/lock ios util version
2025-01-04 17:25:09 -05:00
herrrta
29bba04fdd react-native-ios-utilities major having issues
locked to 4.5.1
2025-01-04 17:24:52 -05:00
herrrta
5a24957e88 Merge pull request #368 from herrrta/fix/332
Add badge to differentiate a movie/series
2025-01-04 17:24:30 -05:00
herrrta
39f2735756 Add badge to differentiate a movie/series
fixes #332
2025-01-04 17:11:01 -05:00
Fredrik Burmester
5dc86d4765 Merge pull request #365 from herrrta/fix/363
Requesting using purple request button doesnt refresh page
2025-01-04 22:00:13 +01:00
herrrta
d13731c28f Requesting using purple request button doesnt refresh page
fixes #363
2025-01-04 15:51:58 -05:00
Fredrik Burmester
7f0446b85f Update build-ios.yaml 2025-01-04 21:13:57 +01:00
Fredrik Burmester
11fbe19f80 Update build-ios.yaml 2025-01-04 20:57:57 +01:00
Fredrik Burmester
5c97b85492 Update build-ios.yaml 2025-01-04 20:50:09 +01:00
Fredrik Burmester
e60cec69f8 Update build-ios.yaml 2025-01-04 20:49:12 +01:00
Fredrik Burmester
7bc1c22770 Merge pull request #355 from Stetsed/master
Implement CI/CD pipeline for iOS building
2025-01-04 20:30:12 +01:00
Stetsed
e86dab5613 Update build-ios.yaml 2025-01-04 19:15:09 +01:00
Stetsed
eeb803223c Allow manual build trigger 2025-01-04 19:04:29 +01:00
Stetsed
1a43f7ef1b Create build-ios.yaml 2025-01-04 19:03:12 +01:00
retardgerman
f4624bdc25 fix: typo 2025-01-04 16:32:46 +01:00
Fredrik Burmester
3c5f2b4079 fix: controls gray overlay 2025-01-04 13:04:23 +01:00
Fredrik Burmester
955190a9cc chore: version 2025-01-04 13:04:15 +01:00
Fredrik Burmester
e1e4f4833c chore: versions 2025-01-04 13:04:02 +01:00
Fredrik Burmester
3b987646a6 fix: adjust to working version 2025-01-03 23:27:52 +01:00
Fredrik Burmester
0e574ea18d fix: alert prompt not working on android 2025-01-03 21:43:07 +01:00
Fredrik Burmester
1a5fcdcb10 chore 2025-01-03 21:15:33 +01:00
Fredrik Burmester
62b00837ec fix: ? 2025-01-03 10:26:12 +01:00
Fredrik Burmester
0fc48497d0 fix: unclear phrasing 2025-01-03 10:24:16 +01:00
Fredrik Burmester
7e12136211 fix: missing text 2025-01-03 10:23:05 +01:00
Fredrik Burmester
7639de153b chore 2025-01-03 10:12:43 +01:00
Fredrik Burmester
ea3cc18b3c fix: item count 2025-01-03 10:12:40 +01:00
Fredrik Burmester
c9fb52086e fix: key warning 2025-01-03 09:55:48 +01:00
Fredrik Burmester
878edc6909 fix: spelling 2025-01-02 22:15:09 +01:00
Fredrik Burmester
74f0aca517 fix: text 2025-01-02 22:08:47 +01:00
Fredrik Burmester
60bb3b905d feat: save previous servers 2025-01-02 21:59:05 +01:00
Fredrik Burmester
fdde5fb56c Merge pull request #350 from herrrta/fix/334
Refresh jellyseerr page when media is requested
2025-01-02 20:49:04 +01:00
Fredrik Burmester
49ae9c6f57 chore 2025-01-02 20:48:44 +01:00
Fredrik Burmester
2254adb8d6 Merge branch 'pr/350' 2025-01-02 20:47:55 +01:00
herrrta
d4c722aeac Refresh jellyseerr page when media is requested
- refetch details when media request is successful
- fix key error on tags
2025-01-02 14:47:36 -05:00
Fredrik Burmester
eefcfb8be5 fix: plugins design stuff 2025-01-02 20:46:34 +01:00
herrrta
4af2712cc0 Refresh jellyseerr page when media is requested
- refetch details when media request is successful
- fix key error on tags
- chore: update bun.lockb
2025-01-02 14:08:15 -05:00
Fredrik Burmester
958b870bf0 Merge pull request #349 from streamyfin/refactor/settings-2
feat: refactor settings
2025-01-02 17:33:42 +01:00
Fredrik Burmester
ce7e1b255f fix: design 2025-01-02 17:32:50 +01:00
Fredrik Burmester
acae4b4544 fix: update deps 2025-01-02 17:32:45 +01:00
Fredrik Burmester
f7bbb20c38 fix: popular lists info 2025-01-02 17:32:29 +01:00
Fredrik Burmester
2c655b9482 chore 2025-01-02 17:25:03 +01:00
Fredrik Burmester
b8dbce6bf2 fix: popular lists plugin 2025-01-02 17:24:43 +01:00
Fredrik Burmester
730823c520 fix: marlin search settings 2025-01-02 17:18:01 +01:00
Fredrik Burmester
77f14a7d5b fix: spelling 2025-01-02 16:50:58 +01:00
Fredrik Burmester
07c7cb7ab5 feat: refactor settings 2025-01-02 16:49:04 +01:00
Fredrik Burmester
5333d53d61 fix: chromecast on android 2025-01-02 12:24:25 +01:00
Fredrik Burmester
82e50b9ba3 fix: crash if item not properly analuzed serverside 2025-01-02 12:24:17 +01:00
Fredrik Burmester
663605b9e8 chore 2025-01-02 12:23:58 +01:00
Fredrik Burmester
00847c8d3d fix: correct routing for actors after favorite tab implementation 2025-01-02 11:45:17 +01:00
retardgerman
f20ad67186 fix: updated links 2025-01-02 08:06:30 +01:00
Fredrik Burmester
91527b83dd Merge pull request #347 from herrrta/fix/333
Make report issue bottom sheet full screen
2025-01-01 20:18:52 +01:00
herrrta
14138151a3 Make report issue bottom sheet full screen
- Used BottomSheetTextInput for keyboard focus + blur behaviors
2025-01-01 14:04:50 -05:00
Fredrik Burmester
6c2bfe2a45 fix: spacing 2025-01-01 18:33:03 +01:00
Fredrik Burmester
996cd36a9e fix: invalidate cache on favorite 2025-01-01 18:30:16 +01:00
Fredrik Burmester
6aa2e00d93 fix: wrong format 2025-01-01 18:26:53 +01:00
Fredrik Burmester
344e0932dc fix: add favorite to series 2025-01-01 18:23:42 +01:00
Fredrik Burmester
eaffffb2f0 Merge branch 'master' of https://github.com/streamyfin/streamyfin 2025-01-01 18:11:20 +01:00
Fredrik Burmester
f6c0513d2d feat: favorite button toggle for movies/episodes 2025-01-01 18:11:18 +01:00
Fredrik Burmester
013f064280 Merge pull request #346 from sldless/master
Github Workflow
2025-01-01 17:54:26 +01:00
sldless
cd2c3f359e Revert "feat: add favorites functionality with FavoriteButton component and layout"
This reverts commit 468f58e531.
2025-01-01 11:47:43 -05:00
sldless
123c6bba05 Revert "Delete bun.lockb"
This reverts commit 347f196a6a.
2025-01-01 11:47:37 -05:00
sldless
a1ea926342 feat: add Discord notification workflow for new pull requests 2025-01-01 11:28:36 -05:00
Fredrik Burmester
6a17ac02af fix: horrizontal padding issue 2025-01-01 17:12:38 +01:00
Fredrik Burmester
815be2a175 Merge pull request #345 from streamyfin/feat/favorites-tab
feat: favorites tab
2025-01-01 16:23:31 +01:00
Fredrik Burmester
ece3bc001f fix: id collision 2025-01-01 16:11:04 +01:00
Fredrik Burmester
27609e7789 feat: favorites tab 2025-01-01 15:46:22 +01:00
sldless
347f196a6a Delete bun.lockb 2024-12-31 22:35:00 -05:00
sldless
468f58e531 feat: add favorites functionality with FavoriteButton component and layout 2024-12-31 22:29:09 -05:00
Fredrik Burmester
a994868be4 chore 2024-12-31 23:13:58 +01:00
Fredrik Burmester
ee6d43e3e8 Merge branch 'master' of https://github.com/streamyfin/streamyfin 2024-12-31 21:47:46 +01:00
Fredrik Burmester
f8d22bb7d6 fix: trailers for movies 2024-12-31 21:47:43 +01:00
Fredrik Burmester
a0391b484d Merge pull request #338 from streamyfin/retardgerman-patch-1
feat: explaining new way to enter Beta in README
2024-12-31 17:25:05 +01:00
150 changed files with 5546 additions and 2683 deletions

View File

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

View File

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

49
.github/workflows/build-ios.yaml vendored Normal file
View File

@@ -0,0 +1,49 @@
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

18
.github/workflows/notification.yaml vendored Normal file
View File

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

4
.gitignore vendored
View File

@@ -35,4 +35,6 @@ credentials.json
*.ipa
.continuerc.json
.vscode/
.vscode/
.idea/
.ruby-lsp

3
.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -1,329 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Lenovo" />
<option name="codename" value="TB370FU" />
<option name="id" value="TB370FU" />
<option name="manufacturer" value="Lenovo" />
<option name="name" value="Tab P12" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1840" />
<option name="screenY" value="2944" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="id" value="comet" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e1q" />
<option name="id" value="e1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q6q" />
<option name="id" value="q6q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1856" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="id" value="tokay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

6
.idea/misc.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
</modules>
</component>
</project>

9
.idea/streamyfin.iml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

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
- 🚀 **Skp intro / credits support**
- 🚀 **Skip 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.
@@ -66,7 +66,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div>
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
### Beta testing
@@ -108,7 +108,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
If you have questions or need support, feel free to reach out:
@@ -117,7 +117,7 @@ If you have questions or need support, feel free to reach out:
## 📝 Credits
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
## ✨ Acknowledgements
@@ -130,4 +130,4 @@ I'd like to thank the following people and projects for their contributions to S
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fredrikburmester/streamyfin&type=Date)](https://star-history.com/#fredrikburmester/streamyfin&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.23.0",
"version": "0.25.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 49,
"versionCode": 50,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -111,7 +111,8 @@
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"]
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withGoogleCastActivity.js"]
],
"experiments": {
"typedRoutes": true

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
@@ -15,6 +16,9 @@ export default function IndexLayout() {
headerLargeTitle: true,
headerTitle: "Home",
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
@@ -49,6 +53,44 @@ export default function IndexLayout() {
title: "Settings",
}}
/>
<Stack.Screen
name="settings/optimized-server/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/marlin-search/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/jellyseerr/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/popular-lists/page"
options={{
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

@@ -4,18 +4,23 @@ import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import {useNavigation, useRouter} from "expo-router";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, {useEffect, useMemo, useRef} from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {toast} from "sonner-native";
import {writeToLog} from "@/utils/log";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
@@ -56,28 +61,29 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
)
})
),
});
}, [downloadedFiles]);
const deleteMovies = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () => deleteFileByType("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () =>
deleteFileByType("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]);
return (
<>
@@ -90,11 +96,11 @@ export default function page() {
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
Queue and active downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
@@ -107,7 +113,9 @@ export default function page() {
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
<Text className="text-xs opacity-50">
{q.item.Type}
</Text>
</View>
<TouchableOpacity
onPress={() => {
@@ -118,7 +126,7 @@ export default function page() {
});
}}
>
<Ionicons name="close" size={24} color="red"/>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
@@ -130,7 +138,7 @@ export default function page() {
</View>
)}
<ActiveDownloads/>
<ActiveDownloads />
</View>
{movies.length > 0 && (
@@ -145,7 +153,7 @@ export default function page() {
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
<MovieCard item={item.item} />
</View>
))}
</View>
@@ -157,13 +165,18 @@ export default function page() {
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
<Text className="text-xs font-bold">
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<View
className="mb-2 last:mb-0"
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
@@ -200,9 +213,15 @@ export default function page() {
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
<Button color="purple" onPress={deleteMovies}>
Delete all Movies
</Button>
<Button color="purple" onPress={deleteShows}>
Delete all TV-Series
</Button>
<Button color="red" onPress={deleteAllMedia}>
Delete all
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>

View File

@@ -23,7 +23,7 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -107,16 +107,16 @@ export default function index() {
setIsConnected(state.isConnected);
});
cleanCacheDirectory()
.then(r => console.log("Cache directory cleaned"))
.catch(e => console.error("Something went wrong cleaning cache directory"))
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
return () => {
unsubscribe();
};
}, []);
const {
data: userViews,
data,
isError: e1,
isLoading: l1,
} = useQuery({
@@ -136,6 +136,11 @@ 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

@@ -0,0 +1,109 @@
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

@@ -1,176 +1,105 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import {useDownload} from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, useLog } from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native";
import * as Progress from "react-native-progress";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { DownloadSettings } from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { storage } from "@/utils/mmkv";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles, appSizeUsage } = useDownload();
const { logs } = useLog();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const insets = useSafeAreaInsets();
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage;
const remaining = await FileSystem.getFreeDiskStorageAsync();
const total = await FileSystem.getTotalDiskCapacityAsync();
return { app, remaining, total, used: (total - remaining) / total };
},
});
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
"Enter the quick connect code",
async (text) => {
if (text) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: text,
userId: user?.Id,
});
if (res.status === 200) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Alert.alert("Success", "Quick connect authorized");
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
}
}
);
};
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
toast.error("Error deleting files");
}
};
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
successHapticFeedback();
};
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => {
logout();
}}
>
<Text className="text-red-600">Log out</Text>
</TouchableOpacity>
),
});
}, []);
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="p-4 flex flex-col gap-y-4">
{/* <Button
onPress={() => {
registerBackgroundFetchAsync();
}}
>
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">User Info</Text>
<UserInfo />
<QuickConnect className="mb-4" />
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View>
<MediaProvider>
<MediaToggles className="mb-4" />
<AudioToggles className="mb-4" />
<SubtitleToggles className="mb-4" />
</MediaProvider>
<View>
<Text className="font-bold text-lg mb-2">Quick connect</Text>
<Button onPress={openQuickConnectAuthCodeInput} color="black">
Authorize
</Button>
</View>
<OtherSettings />
<DownloadSettings />
<SettingToggles />
<PluginSettings />
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2">
{size && <Text>App usage: {size.app.bytesToReadable()}</Text>}
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
color="#9333ea"
width={null}
height={10}
borderRadius={6}
borderWidth={0}
progress={size?.used}
<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
onPress={() => router.push("/settings/logs/page")}
showArrow
title={"Logs"}
/>
{size && (
<Text>
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
{size.total?.bytesToReadable()}
</Text>
)}
</View>
<Button color="red" onPress={onDeleteClicked}>
Delete all downloaded files
</Button>
<Button color="red" onPress={onClearLogsClicked}>
Delete all logs
</Button>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>
<View className="flex flex-col space-y-2">
{logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<Text
className={`
mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">No logs available</Text>
)}
</View>
<ListItem
textColor="red"
onPress={onClearLogsClicked}
title={"Delete All Logs"}
/>
</ListGroup>
</View>
<StorageSettings />
</View>
</ScrollView>
);

View File

@@ -0,0 +1,65 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Switch, View } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = 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 (
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4"
>
<ListGroup>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({
hiddenLibraries: value
? [...(settings.hiddenLibraries || []), view.Id!]
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Select the libraries you want to hide from the Library tab and home page
sections.
</Text>
</DisabledSetting>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import React, {useEffect, useMemo, useState} from "react";
import {
Linking,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success("Saved");
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<DisabledSetting
disabled={disabled}
className="px-4"
>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={"Enable Marlin Search"}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
>
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">URL</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder="http(s)://domain.org:port"
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the Marlin server. The URL should include http or
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Marlin.
</Text>
</Text>
</DisabledSetting>
);
}

View File

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

View File

@@ -0,0 +1,150 @@
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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { Linking, Switch, View } from "react-native";
import {useMemo} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings, updateSettings, pluginSettings] = useSettings();
const handleOpenLink = () => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
);
};
const queryClient = useQueryClient();
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items ?? [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const disabled = useMemo(() => (
pluginSettings?.usePopularPlugin?.locked === true &&
pluginSettings?.mediaListCollectionIds?.locked === true
), [pluginSettings]);
if (!settings) return null;
return (
<DisabledSetting
disabled={disabled}
className="px-4 pt-4"
>
<ListGroup title={"Enable plugin"} className="">
<ListItem
title={"Enable Popular Lists"}
disabled={pluginSettings?.usePopularPlugin?.locked}
onPress={() => {
updateSettings({ usePopularPlugin: true });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.usePopularPlugin}
disabled={pluginSettings?.usePopularPlugin?.locked}
onValueChange={(usePopularPlugin) =>
updateSettings({ usePopularPlugin })
}
/>
</ListItem>
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Popular Lists is a plugin that enables you to show custom Jellyfin lists
on the Streamyfin home page.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Popular Lists.
</Text>
</Text>
{settings.usePopularPlugin && (
<>
{!isLoadingMediaListCollections ? (
<>
{mediaListCollections?.length === 0 ? (
<Text className="text-xs opacity-50 p-4">
No collections found. Add some in Jellyfin.
</Text>
) : (
<>
<ListGroup title="Media List Collections" className="mt-4">
{mediaListCollections?.map((mlc) => (
<ListItem
key={mlc.Id}
title={mlc.Name}
disabled={pluginSettings?.mediaListCollectionIds?.locked}
>
<Switch
disabled={pluginSettings?.mediaListCollectionIds?.locked}
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({
mediaListCollectionIds:
settings.mediaListCollectionIds.includes(
mlc.Id!
)
? settings.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [
...settings.mediaListCollectionIds,
mlc.Id!,
],
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Select the lists you want displayed on the home screen.
</Text>
</>
)}
</>
) : (
<Loader />
)}
</>
)}
</DisabledSetting>
);
}

View File

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

View File

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

View File

@@ -1,61 +1,65 @@
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 { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewText } from "@/components/OverviewText";
import { GenreTags } from "@/components/GenreTags";
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,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Input } from "@/components/common/Input";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { JellyserrRatings } from "@/components/Ratings";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as DropdownMenu from "zeego/dropdown-menu";
import RequestModal from "@/components/jellyseerr/RequestModal";
import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const {
mediaTitle,
releaseYear,
canRequest: canRequestString,
posterSrc,
...result
} = params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const canRequest = canRequestString === "true";
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {
data: details,
isFetching,
isLoading,
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
@@ -64,6 +68,7 @@ const Page: React.FC = () => {
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
refetchInterval: 0,
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
@@ -71,6 +76,8 @@ const Page: React.FC = () => {
},
});
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -94,18 +101,40 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(
() =>
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),
}),
[details, result, requestMedia]
);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}
if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body)
return
}
requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
[details]
)
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<ItemActions item={details} />
</TouchableOpacity>
),
});
}
}, [details]);
return (
<View
@@ -129,7 +158,10 @@ const Page: React.FC = () => {
height: "100%",
}}
source={{
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces"
),
}}
/>
) : (
@@ -178,7 +210,9 @@ const Page: React.FC = () => {
<View className="mb-4">
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{canRequest ? (
{isLoading || isFetching ? (
<Button loading={true} disabled={true} color="purple"></Button>
) : canRequest ? (
<Button color="purple" onPress={request}>
Request
</Button>
@@ -206,11 +240,32 @@ const Page: React.FC = () => {
isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
}
/>
)}
<DetailFacts
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
details={details}
/>
<Cast details={details} />
</View>
</View>
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
isAnime={isAnime}
onRequested={() => {
advancedReqModalRef?.current?.close()
refetch()
}}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
@@ -274,17 +329,20 @@ const Page: React.FC = () => {
</DropdownMenu.Root>
</View>
<Input
className="w-full"
placeholder="(optional) Describe the issue..."
value={issueMessage}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="none"
maxLength={254}
onChangeText={setIssueMessage}
/>
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode="always"
placeholder="(optional) Describe the issue..."
placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className="mt-auto" onPress={submitIssue} color="purple">
Submit

View File

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

View File

@@ -1,10 +1,8 @@
import { Text } from "@/components/common/Text";
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Ratings } from "@/components/Ratings";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesActions } from "@/components/series/SeriesActions";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -38,7 +36,6 @@ const page: React.FC = () => {
userId: user?.Id,
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 60 * 1000,
});
@@ -81,10 +78,13 @@ const page: React.FC = () => {
navigation.setOptions({
headerRight: () =>
!isLoading &&
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className="flex flex-row items-center space-x-2">
<AddToFavorites item={item} type="series" />
<DownloadItems
size="large"
title="Download Series"
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
@@ -101,7 +101,7 @@ const page: React.FC = () => {
</View>
),
});
}, [allEpisodes, isLoading]);
}, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null;

View File

@@ -41,7 +41,6 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -151,6 +150,8 @@ const Page = () => {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
} else if (library.CollectionType === "music") {
itemType = "MusicAlbum";
}
const response = await getItemsApi(api).getItems({

View File

@@ -6,7 +6,7 @@ import { Platform } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
export default function IndexLayout() {
const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings?.libraryOptions) return null;
@@ -19,9 +19,13 @@ export default function IndexLayout() {
headerLargeTitle: true,
headerTitle: "Library",
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
!pluginSettings?.libraryOptions?.locked &&
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons

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 } from "react";
import { useEffect, useMemo } 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 () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000 * 60,
staleTime: 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 (!data)
if (!libraries)
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={data}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>

View File

@@ -1,4 +1,7 @@
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
import {
commonScreenOptions,
nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
@@ -11,6 +14,9 @@ export default function SearchLayout() {
headerShown: true,
headerLargeTitle: true,
headerTitle: "Search",
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
@@ -29,10 +35,10 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="jellyseerr/page"
options={commonScreenOptions}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
</Stack>
);
}

View File

@@ -2,14 +2,17 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import {
BaseItemDto,
BaseItemKind,
@@ -20,7 +23,6 @@ import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
PropsWithChildren,
useCallback,
useEffect,
useLayoutEffect,
@@ -30,13 +32,6 @@ import React, {
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, 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";
type SearchType = "Library" | "Discover";
@@ -95,6 +90,7 @@ export default function search() {
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else {
if (!settings?.marlinServerUrl) return [];
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
@@ -102,6 +98,7 @@ export default function search() {
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
@@ -147,48 +144,6 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
queryKey: ["search", "jellyseerrResults", debouncedSearch],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(debouncedSearch).toString(),
page: 1, // todo: maybe rework page & page-size if first results are not enough...
language: "en",
});
return response?.results;
},
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(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
[jellyseerrResults]
);
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
[jellyseerrResults]
);
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -268,25 +223,13 @@ export default function search() {
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length ||
jellyseerrMovieResults?.length ||
jellyseerrTvResults?.length
actors?.length
);
}, [
artists,
episodes,
albums,
songs,
movies,
series,
collections,
actors,
jellyseerrResults,
]);
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
return (
<>
@@ -298,7 +241,7 @@ export default function search() {
paddingRight: insets.right,
}}
>
<View className="flex flex-col pt-2">
<View className="flex flex-col">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
@@ -318,7 +261,7 @@ export default function search() {
text="Library"
textClass="p-1"
className={
searchType === "Library" ? "bg-neutral-600" : undefined
searchType === "Library" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
@@ -327,21 +270,19 @@ export default function search() {
text="Discover"
textClass="p-1"
className={
searchType === "Discover" ? "bg-neutral-600" : undefined
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
</View>
)}
{!!q && (
<View className="px-4 flex flex-col space-y-2">
<Text className="text-neutral-500 ">
Results for <Text className="text-purple-600">{q}</Text>
</Text>
</View>
)}
{searchType === "Library" && (
<>
<View className="mt-2">
<LoadingSkeleton isLoading={loading} />
</View>
{searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
@@ -466,126 +407,39 @@ export default function search() {
</TouchableItemRouter>
)}
/>
</>
)}
{searchType === "Discover" && (
<>
<SearchItemWrapper
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
</>
</View>
) : (
<JellyserrIndexPage searchQuery={debouncedSearch} />
)}
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
</View>
) : noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 && searchType === "Library" ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
<View className="flex flex-col px-4">
{sortBy?.(
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
"order"
).map((slide) => (
<DiscoverSlide key={slide.id} slide={slide} />
))}
</View>
) : null}
{searchType === "Library" && (
<>
{!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</>
)}
</View>
</ScrollView>
</>
);
}
type Props<T> = {
ids?: string[] | null;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
};
const SearchItemWrapper = <T extends unknown>({
ids,
items,
renderItem,
header,
}: PropsWithChildren<Props<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data && (!items || items.length === 0)) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data && data?.length > 0
? data.map((item) => renderItem(item))
: items && items?.length > 0
? items.map((i) => renderItem(i))
: undefined}
</ScrollView>
</>
);
};

View File

@@ -1,7 +1,7 @@
import React from "react";
import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
import { withLayoutContext } from "expo-router";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
@@ -13,12 +13,13 @@ 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,
@@ -29,11 +30,28 @@ 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" />
<NativeTabs
sidebarAdaptable
sidebarAdaptable={false}
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarActiveTintColor={Colors.primary}
@@ -48,7 +66,10 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: () => ({ sfSymbol: "house" }),
: ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
}}
/>
<NativeTabs.Screen
@@ -59,7 +80,26 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: () => ({ sfSymbol: "magnifyingglass" }),
: ({ focused }) =>
focused
? { sfSymbol: "magnifyingglass" }
: { sfSymbol: "magnifyingglass" },
}}
/>
<NativeTabs.Screen
name="(favorites)"
options={{
title: "Favorites",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
focused
? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png")
: ({ focused }) =>
focused
? { sfSymbol: "heart.fill" }
: { sfSymbol: "heart" },
}}
/>
<NativeTabs.Screen
@@ -70,7 +110,10 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: () => ({ sfSymbol: "rectangle.stack" }),
: ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
}}
/>
<NativeTabs.Screen
@@ -81,8 +124,11 @@ export default function TabLayout() {
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? () => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }),
? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}}
/>
</NativeTabs>

View File

@@ -27,7 +27,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -48,6 +48,7 @@ import {
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -68,9 +69,11 @@ export default function page() {
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
}, []);
const {
@@ -104,7 +107,6 @@ export default function page() {
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
console.log("Offline:", offline);
if (offline) {
const item = await getDownloadedItem(itemId);
if (item) return item.item;
@@ -128,7 +130,6 @@ export default function page() {
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
console.log("Offline:", offline);
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
@@ -177,7 +178,7 @@ export default function page() {
const togglePlay = useCallback(async () => {
if (!api) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
if (isPlaying) {
await videoRef.current?.pause();
@@ -195,8 +196,6 @@ export default function page() {
playSessionId: stream.sessionId,
});
}
console.log("Actually marked as paused");
} else {
videoRef.current?.play();
if (!offline && stream) {
@@ -339,7 +338,6 @@ export default function page() {
React.useCallback(() => {
return async () => {
stop();
console.log("Unmounted");
};
}, [])
);
@@ -349,10 +347,8 @@ export default function page() {
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
console.log("App has come to the foreground!");
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
console.log("App has gone to the background!");
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
@@ -418,6 +414,8 @@ export default function page() {
}
}
const insets = useSafeAreaInsets();
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
@@ -442,7 +440,8 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView

View File

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

View File

@@ -20,7 +20,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -48,6 +48,7 @@ const Player = () => {
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -58,7 +59,7 @@ const Player = () => {
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
}, []);
const progress = useSharedValue(0);
@@ -167,7 +168,7 @@ const Player = () => {
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
@@ -260,13 +261,6 @@ const Player = () => {
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration);
console.log(
"onProgress ~",
ticks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
// TODO: Use this when streaming with HLS url, but NOT when direct playing
// TODO: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
@@ -339,11 +333,7 @@ const Player = () => {
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
console.log(
"Setting selected text track",
subtitleIndex,
embeddedTrackIndex
);
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: embeddedTrackIndex,
@@ -398,7 +388,6 @@ const Player = () => {
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
}}
>
{videoSource ? (
@@ -439,7 +428,6 @@ const Player = () => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
console.log("onAudioTracks: ", e.audioTracks);
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
@@ -493,7 +481,6 @@ const Player = () => {
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
console.log("setAudioTrack ~", i);
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,

View File

@@ -1,53 +0,0 @@
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Button, Dimensions } from "react-native";
import { Alert, View } from "react-native";
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
export default function page() {
const searchParams = useGlobalSearchParams();
const navigation = useNavigation();
console.log(searchParams);
const { url } = searchParams as { url: string };
const videoId = useMemo(() => {
return url.split("v=")[1];
}, [url]);
const [playing, setPlaying] = useState(false);
const onStateChange = useCallback((state: PLAYER_STATES) => {
if (state === "ended") {
setPlaying(false);
Alert.alert("video has finished playing!");
}
}, []);
const togglePlaying = useCallback(() => {
setPlaying((prev) => !prev);
}, []);
useEffect(() => {
navigation.setOptions({
headerShown: false,
});
togglePlaying();
}, []);
const screenWidth = Dimensions.get("screen").width;
const screenHeight = Dimensions.get("screen").height;
return (
<View className="flex flex-col bg-black items-center justify-center h-full">
<YoutubePlayer
height={300}
play={playing}
videoId={videoId}
onChangeState={onStateChange}
width={screenWidth}
/>
</View>
);
}

View File

@@ -1,4 +1,5 @@
import "@/augmentations";
import { Text } from "@/components/common/Text";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
@@ -36,7 +37,7 @@ import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { Appearance, AppState } from "react-native";
import { Appearance, AppState, TouchableOpacity } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
@@ -335,13 +336,6 @@ function Layout() {
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/trailer/page"
options={{
presentation: "modal",
title: "",
}}
/>
<Stack.Screen
name="login"
options={{

View File

@@ -1,14 +1,20 @@
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 } from "@expo/vector-icons";
import {
Ionicons,
MaterialCommunityIcons,
MaterialIcons,
} 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";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@@ -38,7 +44,6 @@ 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;
@@ -76,8 +81,10 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
className="flex flex-row items-center"
>
<Ionicons name="chevron-back" size={24} color="white" />
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">Change server</Text>
</TouchableOpacity>
) : null,
});
@@ -94,9 +101,9 @@ const Login: React.FC = () => {
}
} catch (error) {
if (error instanceof Error) {
setError(error.message);
Alert.alert("Connection failed", error.message);
} else {
setError("An unexpected error occurred");
Alert.alert("Connection failed", "An unexpected error occurred");
}
} finally {
setLoading(false);
@@ -120,7 +127,7 @@ const Login: React.FC = () => {
* - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console.
*/
async function checkUrl(url: string) {
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
try {
@@ -130,15 +137,18 @@ const Login: React.FC = () => {
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return url;
}
return undefined;
} catch {
return undefined;
} finally {
setLoadingServerCheck(false);
}
}
}, []);
/**
* Handles the connection attempt to a Jellyfin server.
@@ -156,7 +166,7 @@ const Login: React.FC = () => {
* - Sets the server address using `setServer` if the connection is successful.
*
*/
const handleConnect = async (url: string) => {
const handleConnect = useCallback(async (url: string) => {
url = url.trim();
const result = await checkUrl(url);
@@ -170,7 +180,7 @@ const Login: React.FC = () => {
}
setServer({ address: url });
};
}, []);
const handleQuickConnect = async () => {
try {
@@ -187,128 +197,131 @@ const Login: React.FC = () => {
}
};
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">{serverURL}</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
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 }}>
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<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">
Make sure to include http or https
</Text>
</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>
{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
</Text>
<Input
aria-label="Server URL"
placeholder="http(s)://your-server.com"
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
}}
/>
</View>
</View>
</>
)}
</KeyboardAvoidingView>
</SafeAreaView>
);

BIN
assets/icons/heart.fill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/icons/heart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

46
augmentations/api.ts Normal file
View File

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

View File

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

View File

@@ -13,5 +13,10 @@ MMKV.prototype.get = function <T> (key: string): T | undefined {
}
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
this.set(key, JSON.stringify(value));
if (value === undefined) {
this.delete(key)
}
else {
this.set(key, JSON.stringify(value));
}
}

View File

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

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1,114 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacityProps, View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
item: BaseItemDto;
type: "item" | "series";
}
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const isFavorite = useMemo(() => {
return item.UserData?.IsFavorite;
}, [item.UserData?.IsFavorite]);
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
queryClient.setQueryData<BaseItemDto | undefined>(
[type, item.Id],
(old) => {
if (!old) return old;
return {
...old,
...newData,
UserData: { ...old.UserData, ...newData.UserData },
};
}
);
};
const markFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).markFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: true } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
},
});
const unmarkFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).unmarkFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: false } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
},
});
return (
<View {...props}>
<RoundButton
size="large"
icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={() => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/>
</View>
);
};

View File

@@ -27,6 +27,10 @@ export const BITRATES: Bitrate[] = [
key: "2 Mb/s",
value: 2000000,
},
{
key: "1 Mb/s",
value: 1000000,
},
{
key: "500 Kb/s",
value: 500000,

View File

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

View File

@@ -34,6 +34,7 @@ export const Chromecast: React.FC<Props> = ({
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
@@ -64,6 +65,7 @@ export const Chromecast: React.FC<Props> = ({
}}
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);
@@ -77,6 +79,7 @@ export const Chromecast: React.FC<Props> = ({
}}
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -74,7 +74,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
);

View File

@@ -1,6 +1,6 @@
// GenreTags.tsx
import React from "react";
import {View, ViewProps} from "react-native";
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
import { Text } from "./common/Text";
interface TagProps {
@@ -8,14 +8,15 @@ interface TagProps {
textClass?: ViewProps["className"]
}
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
text,
textClass,
textStyle,
...props
}) => {
return (
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
<Text className={textClass}>{text}</Text>
<Text className={textClass} style={textStyle}>{text}</Text>
</View>
);
};
@@ -26,7 +27,7 @@ export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-x
return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
{tags.map((tag, idx) => (
<View>
<View key={idx}>
<Tag key={idx} textClass={textClass} text={tag}/>
</View>
))}

View File

@@ -15,12 +15,12 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
@@ -31,10 +31,10 @@ import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { AddToFavorites } from "./AddToFavorites";
export type SelectedOptions = {
bitrate: Bitrate;
@@ -91,6 +91,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" />
<PlayedStatus item={item} />
<AddToFavorites item={item} type="item" />
</View>
)}
</View>

View File

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

View File

@@ -175,6 +175,8 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return (
<View className="flex-row flex-wrap gap-2">
<Badge

View File

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

View File

@@ -29,6 +29,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
[item, selected]
);
const commonPrefix = useMemo(() => {
const mediaSources = item.MediaSources || [];
if (!mediaSources.length) return "";
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name.length; i++) {
const char = mediaSources[0].Name[i];
if (mediaSources.every((source) => source.Name[i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
}
return commonPrefix;
}, [item.MediaSources]);
const name = (name?: string | null) => {
return name?.replace(commonPrefix, "").toLowerCase();
};
return (
<View
className="flex shrink"
@@ -63,9 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
{`${name(source.Name)}`}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
@@ -74,9 +93,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
</View>
);
};
const name = (name?: string | null) => {
if (name && name.length > 40)
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
return name;
};

View File

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

View File

@@ -32,7 +32,7 @@ import Animated, {
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -64,6 +64,7 @@ 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) => {
@@ -79,7 +80,7 @@ export const PlayButton: React.FC<Props> = ({
const onPress = useCallback(async () => {
if (!item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,

View File

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

View File

@@ -6,10 +6,10 @@ import {
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
onPress?: () => void,
onPress?: () => void;
icon?: keyof typeof Ionicons.glyphMap;
background?: boolean;
size?: "default" | "large";
@@ -29,10 +29,11 @@ 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) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
}
onPress?.();
};

View File

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

View File

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

View File

@@ -1,17 +1,24 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren } from "react";
import { PropsWithChildren, useCallback } 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;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
if (item.CollectionType === "livetv") {
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string
) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
@@ -31,7 +38,7 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
}
if (item.Type === "Person") {
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
@@ -61,85 +68,51 @@ 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 markAsPlayedStatus = useMarkAsPlayed(item);
const showActionSheet = useCallback(() => {
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
if (from === "(home)" || from === "(search)" || from === "(libraries)")
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
async (selectedIndex) => {
if (selectedIndex === 0) {
await markAsPlayedStatus(true);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else if (selectedIndex === 1) {
await markAsPlayedStatus(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}
);
}, [showActionSheetWithOptions, markAsPlayedStatus]);
if (
from === "(home)" ||
from === "(search)" ||
from === "(libraries)" ||
from === "(favorites)"
)
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={() => {
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);
}}
{...props}
>
{children}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
<ContextMenu.Item
key="item-1"
onSelect={() => {
markAsPlayedStatus(true);
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key="item-1-title">
Mark as watched
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "green", // Changed to green for "watched"
light: "green",
},
}}
androidIconName="checkmark-circle"
></ContextMenu.ItemIcon>
</ContextMenu.Item>
<ContextMenu.Item
key="item-2"
onSelect={() => {
markAsPlayedStatus(false);
}}
shouldDismissMenuOnSelect
destructive
>
<ContextMenu.ItemTitle key="item-2-title">
Mark as not watched
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
pointSize: 18, // Adjusted for better visibility
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "red", // Changed to red for "not watched"
light: "red",
},
// Removed paletteColors as it's not necessary in this case
}}
androidIconName="eye-slash"
></ContextMenu.ItemIcon>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<TouchableOpacity
onLongPress={showActionSheet}
onPress={() => {
const url = itemRouter(item, from);
// @ts-expect-error
router.push(url);
}}
{...props}
>
{children}
</TouchableOpacity>
);
};

View File

@@ -1,7 +1,6 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
@@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import {
ActivityIndicator,
TouchableOpacity,
@@ -62,7 +60,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
if (settings?.downloadMethod === "optimized") {
if (settings?.downloadMethod === DownloadMethod.Optimized) {
try {
const tasks = await checkForExistingDownloads();
for (const task of tasks) {

View File

@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
@@ -26,6 +26,7 @@ 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!);
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
successHapticFeedback();
}
}, [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 * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
@@ -28,6 +28,7 @@ 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);
@@ -43,7 +44,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
successHapticFeedback();
}
}, [deleteFile, item.Id]);

View File

@@ -0,0 +1,119 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { View } from "react-native";
import { ScrollingCollectionList } from "./ScrollingCollectionList";
import { useCallback } from "react";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
export const Favorites = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const fetchFavoritesByType = useCallback(
async (itemType: BaseItemKind) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id!,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
limit: 20,
includeItemTypes: [itemType],
});
return response.data.Items || [];
},
[api, user]
);
const fetchFavoriteSeries = useCallback(
() => fetchFavoritesByType("Series"),
[fetchFavoritesByType]
);
const fetchFavoriteMovies = useCallback(
() => fetchFavoritesByType("Movie"),
[fetchFavoritesByType]
);
const fetchFavoriteEpisodes = useCallback(
() => fetchFavoritesByType("Episode"),
[fetchFavoritesByType]
);
const fetchFavoriteVideos = useCallback(
() => fetchFavoritesByType("Video"),
[fetchFavoritesByType]
);
const fetchFavoriteBoxsets = useCallback(
() => fetchFavoritesByType("BoxSet"),
[fetchFavoritesByType]
);
const fetchFavoritePlaylists = useCallback(
() => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType]
);
const fetchFavoriteMusicAlbum = useCallback(
() => fetchFavoritesByType("MusicAlbum"),
[fetchFavoritesByType]
);
const fetchFavoriteAudio = useCallback(
() => fetchFavoritesByType("Audio"),
[fetchFavoritesByType]
);
return (
<View className="flex flex-co gap-y-4">
<ScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title="Series"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title="Movies"
hideIfEmpty
orientation="vertical"
/>
<ScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title="Episodes"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title="Videos"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title="Boxsets"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title="Playlists"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMusicAlbum}
queryKey={["home", "favorites", "musicAlbums"]}
title="Music Albums"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteAudio}
queryKey={["home", "favorites", "audio"]}
title="Audio"
hideIfEmpty
/>
</View>
);
};

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 * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends ViewProps {}
@@ -84,21 +84,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const width = Dimensions.get("screen").width;
if (settings?.usePopularPlugin === false) return null;
if (l1 || l2) return null;
if (!popularItems) return null;
return (
<View className="flex flex-col items-center" {...props}>
<View className="flex flex-col items-center mt-2" {...props}>
<Carousel
autoPlay={true}
autoPlayInterval={3000}
loop={true}
ref={ref}
autoPlay={false}
loop={true}
snapEnabled={true}
mode="parallax"
modeConfig={{
parallaxScrollingScale: 0.86,
parallaxScrollingOffset: 100,
}}
width={width}
height={204}
data={popularItems}
onProgressChange={progress}
renderItem={({ item, index }) => <RenderItem item={item} />}
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
/>
<Pagination.Basic
progress={progress}
@@ -122,6 +128,7 @@ 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;
@@ -147,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
lightHapticFeedback();
// @ts-ignore
if (url) router.push(url);
}, [item, from]);

View File

@@ -18,6 +18,7 @@ interface Props extends ViewProps {
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean;
}
export const ScrollingCollectionList: React.FC<Props> = ({
@@ -26,10 +27,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
...props
}) => {
// console.log(queryKey);
const { data, isLoading } = useQuery({
queryKey: queryKey,
queryFn,
@@ -41,8 +41,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
if (disabled || !title) return null;
if (hideIfEmpty === true && data?.length === 0) return null;
return (
<View {...props} className="">
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
@@ -82,15 +84,13 @@ export const ScrollingCollectionList: React.FC<Props> = ({
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{data?.map((item, index) => (
{data?.map((item) => (
<TouchableItemRouter
item={item}
key={index}
className={`
mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
key={item.Id}
className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />

View File

@@ -1,8 +1,10 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface StepperProps {
value: number,
disabled?: boolean,
step: number,
min: number,
max: number,
@@ -12,6 +14,7 @@ interface StepperProps {
export const Stepper: React.FC<StepperProps> = ({
value,
disabled,
step,
min,
max,
@@ -19,7 +22,11 @@ export const Stepper: React.FC<StepperProps> = ({
appendValue
}) => {
return (
<View className="flex flex-row items-center">
<DisabledSetting
disabled={disabled === true}
showText={false}
className="flex flex-row items-center"
>
<TouchableOpacity
onPress={() => onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
@@ -39,6 +46,6 @@ export const Stepper: React.FC<StepperProps> = ({
>
<Text>+</Text>
</TouchableOpacity>
</View>
</DisabledSetting>
)
}

View File

@@ -0,0 +1,39 @@
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 &&
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

@@ -0,0 +1,218 @@
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-col justify-between py-2" {...props}>
<Text className="font-bold text-start">{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

@@ -0,0 +1,159 @@
import Discover from "@/components/jellyseerr/discover/Discover";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import React, { useMemo } from "react";
import { View, ViewProps } from "react-native";
import {
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "../common/Text";
import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
interface Props extends ViewProps {
searchQuery: string;
}
export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1);
const {
data: jellyseerrDiscoverSettings,
isFetching: f1,
isLoading: l1,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!jellyseerrApi && searchQuery.length == 0,
});
const {
data: jellyseerrResults,
isFetching: f2,
isLoading: l2,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "results", searchQuery],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(searchQuery).toString(),
page: 1,
language: "en",
});
return response?.results;
},
enabled: !!jellyseerrApi && searchQuery.length > 0,
});
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
useAnimatedReaction(
() => f1 || f2 || l1 || l2,
(isLoading) => {
if (isLoading) {
opacity.value = withTiming(1, { duration: 200 });
} else {
opacity.value = withTiming(0, { duration: 200 });
}
}
);
const jellyseerrMovieResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
[jellyseerrResults]
);
const jellyseerrTvResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
[jellyseerrResults]
);
const jellyseerrPersonResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === "person"
) as PersonResult[],
[jellyseerrResults]
);
if (!searchQuery.length)
return (
<View className="flex flex-col">
<Discover sliders={jellyseerrDiscoverSettings} />
</View>
);
return (
<View>
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
{!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length &&
!f1 &&
!f2 &&
!l1 &&
!l2 && (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{searchQuery}"
</Text>
</View>
)}
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<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>
</View>
);
};

View File

@@ -0,0 +1,37 @@
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

@@ -2,7 +2,6 @@ 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;
@@ -10,7 +9,7 @@ interface Props {
onPress?: () => void;
}
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -69,4 +68,4 @@ const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
)
}
export default JellyseerrIconStatus;
export default JellyseerrStatusIcon;

View File

@@ -0,0 +1,160 @@
import React, {
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {Dimensions, View, ViewProps} from "react-native";
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 { FlashList } from "@shopify/flash-list";
import {useFocusEffect} from "expo-router";
const ANIMATION_ENTER = 250;
const ANIMATION_EXIT = 250;
const BACKDROP_DURATION = 5000;
type Render = React.ComponentType<any>
| React.ReactElement
| null
| undefined;
interface Props<T> {
data: T[]
images: string[];
logo?: React.ReactElement;
HeaderContent?: () => React.ReactElement;
MainContent?: () => React.ReactElement;
listHeader: string;
renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
const ParallaxSlideShow = <T extends unknown>({
data,
images,
logo,
HeaderContent,
MainContent,
listHeader,
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>
) => {
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
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 (images?.length) {
enterAnimation().start();
const intervalId = setInterval(() => {
Animated.sequence([
enterAnimation(),
exitAnimation()
]).start(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
})
}, BACKDROP_DURATION);
return () => {
clearInterval(intervalId)
};
}
}, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
onEndReached={onEndReached}
headerImage={
<Animated.Image
key={images?.[currentIndex]}
id={images?.[currentIndex]}
source={{
uri: images?.[currentIndex],
}}
style={{
width: "100%",
height: "100%",
opacity: fadeAnim,
}}
/>
}
logo={logo}
>
<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">
{HeaderContent && HeaderContent()}
</View>
</View>
{MainContent && MainContent()}
<View>
<FlashList
data={data}
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">{listHeader}</Text>
}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
//@ts-ignore
renderItem={({ item, index}) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
/>
</View>
</View>
</ParallaxScrollView>
</View>
);
}
export default ParallaxSlideShow;

View File

@@ -0,0 +1,42 @@
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/person/${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

@@ -0,0 +1,233 @@
import React, {forwardRef, useCallback, useMemo, useState} from "react";
import {View, ViewProps} from "react-native";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {useQuery} from "@tanstack/react-query";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import Dropdown from "@/components/common/Dropdown";
import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types";
import {Button} from "@/components/Button";
import {Text} from "@/components/common/Text";
interface Props {
id: number;
title: string,
type: MediaType;
isAnime?: boolean;
is4k?: boolean;
onRequested?: () => void;
}
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id,
title,
type,
isAnime = false,
onRequested,
...props
}, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
const [requestOverrides, setRequestOverrides] =
useState<MediaRequestBody>({
mediaId: Number(id),
mediaType: type,
userId: jellyseerrUser?.id
});
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
const {data: serviceSettings} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service'],
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: 'always'
});
const {data: users} = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: 'always'
});
const defaultService = useMemo(
() => serviceSettings?.find?.(v => v.isDefault),
[serviceSettings]
);
const {data: defaultServiceDetails} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id
}))
return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id)
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
refetchOnMount: 'always',
});
const defaultProfile: QualityProfile = useMemo(
() => defaultServiceDetails?.profiles
.find(p =>
p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId)
),
[defaultServiceDetails]
);
const defaultFolder: RootFolder = useMemo(
() => defaultServiceDetails?.rootFolders
.find(f =>
f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory)
),
[defaultServiceDetails]
);
const defaultTags: Tag[] = useMemo(
() => {
const tags = defaultServiceDetails?.tags
.filter(t =>
(isAnime
? defaultServiceDetails?.server.activeAnimeTags
: defaultServiceDetails?.server.activeTags
)?.includes(t.id)
) ?? []
console.log(tags)
return tags
},
[defaultServiceDetails]
);
const seasonTitle = useMemo(
() => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined,
[modalRequestProps?.seasons]
);
const request = useCallback(() => {requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
{
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile.id,
rootFolder: defaultFolder.path,
tags: defaultTags.map(t => t.id),
...modalRequestProps,
...requestOverrides
},
onRequested
)
}, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={() => setModalRequestProps(undefined)}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
}
>
{(data) => {
setModalRequestProps(data?.data as MediaRequestBody)
return <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">Advanced</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
</View>
<View className="flex flex-col space-y-2">
{(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={"Quality Profile"}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={"Quality Profile"}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={"Root Folder"}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={"Root Folder"}
/>
<Dropdown
multi={true}
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={"Tags"}
onSelected={(...item) =>
item && setRequestOverrides((prev) => ({
...prev,
tags: item.map(i => i.id)
}))
}
title={"Tags"}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={"Request As"}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={"Request As"}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
Request
</Button>
</View>
</BottomSheetView>
}}
</BottomSheetModal>
);
});
export default RequestModal;

View File

@@ -0,0 +1,41 @@
import React, {useCallback} from "react";
import {
useJellyseerr,
} from "@/hooks/useJellyseerr";
import {TouchableOpacity, ViewProps} from "react-native";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import {router, useSegments} from "expo-router";
const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewProps> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2];
const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
params: {id, image, name, type: slide.type }
}), [slide]);
return (
<Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
id={item.id.toString()}
url={jellyseerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
/>
</TouchableOpacity>
)}
/>
);
};
export default CompanySlide;

View File

@@ -0,0 +1,47 @@
import React, {useMemo} from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import {sortBy} from "lodash";
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
import {View} from "react-native";
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
interface Props {
sliders?: DiscoverSlider[];
}
const Discover: React.FC<Props> = ({ sliders }) => {
if (!sliders)
return;
const sortedSliders = useMemo(
() => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'),
[sliders]
);
return (
<View className="flex flex-col space-y-4 mb-8">
{sortedSliders.map(slide => {
switch (slide.type) {
case DiscoverSliderType.NETWORKS:
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
case DiscoverSliderType.STUDIOS:
return <CompanySlide key={slide.id} slide={slide} data={studios}/>
case DiscoverSliderType.MOVIE_GENRES:
case DiscoverSliderType.TV_GENRES:
return <GenreSlide key={slide.id} slide={slide} />
case DiscoverSliderType.TRENDING:
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
return <MovieTvSlide key={slide.id} slide={slide}/>
}
})}
</View>
)
};
export default Discover;

View File

@@ -0,0 +1,59 @@
import React from "react";
import {StyleSheet, View, ViewProps} from "react-native";
import {Image, ImageContentFit} from "expo-image";
import {Text} from "@/components/common/Text";
import {LinearGradient} from "expo-linear-gradient";
export const textShadowStyle = StyleSheet.create({
shadow: {
shadowColor: "#000",
shadowOffset: {
width: 1,
height: 1,
},
shadowOpacity: 1,
shadowRadius: .5,
elevation: 6,
}
})
const GenericSlideCard: React.FC<{id: string; url?: string, title?: string, colors?: string[], contentFit?: ImageContentFit} & ViewProps> = ({
id,
url,
title,
colors = ['#9333ea', 'transparent'],
contentFit = "contain",
...props
}) => (
<>
<LinearGradient
colors={colors}
start={{x: 0.5, y: 1.75}}
end={{x: 0.5, y: 0}}
className="rounded-xl"
>
<View className="rounded-xl" {...props}>
<Image
key={id}
id={id}
source={url ? {uri: url} : null}
cachePolicy={"memory-disk"}
contentFit={contentFit}
style={{
aspectRatio: "4/3",
}}
/>
{title &&
<View
className="absolute justify-center top-0 left-0 right-0 bottom-0 items-center"
>
<Text className="text-center font-bold" style={textShadowStyle.shadow}>{title}</Text>
</View>
}
</View>
</LinearGradient>
</>
);
export default GenericSlideCard;

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