Compare commits

..

17 Commits

Author SHA1 Message Date
Fredrik Burmester
ef851ae3f5 chore: debugging counter 2025-02-21 13:29:40 +01:00
Fredrik Burmester
349899d840 Merge branch 'develop' into feat/recently-added-notifications 2025-02-21 12:06:00 +01:00
Fredrik Burmester
e1561dbd82 fix: return NewData for initial run 2025-02-21 11:26:32 +01:00
Fredrik Burmester
e217fcff36 fix: refactor settings location for notifications 2025-02-21 10:49:26 +01:00
Fredrik Burmester
b402cf7f10 fix: add return type 2025-02-21 10:49:14 +01:00
Fredrik Burmester
f3b77b8547 fix: return correct type for notifications to work 2025-02-21 10:49:02 +01:00
Fredrik Burmester
c0643f564d fix: include count 2025-02-20 21:42:34 +01:00
Fredrik Burmester
893cedcf36 Merge branch 'develop' into feat/recently-added-notifications 2025-02-20 20:34:40 +01:00
Fredrik Burmester
69db54a66c fix: include series, increase to 30 items 2025-02-20 20:34:20 +01:00
Fredrik Burmester
268e93effb fix: time 2025-02-20 20:34:08 +01:00
Fredrik Burmester
8fe0089131 fix: task not registered properly 2025-02-20 20:34:02 +01:00
Fredrik Burmester
82ced0f101 fix: don't rerender function 2025-02-20 17:25:47 +01:00
Fredrik Burmester
5447c36bcd chore 2025-02-20 17:24:03 +01:00
Fredrik Burmester
988eede36f fix: change to 10 min as thie is the default and min value 2025-02-20 17:23:55 +01:00
Fredrik Burmester
a472d48b3b fix: 3 min not seconds interval 2025-02-20 17:21:33 +01:00
Fredrik Burmester
1fd4598ba8 chore 2025-02-20 16:31:32 +01:00
Fredrik Burmester
63ea7d128f feat: recently added notifications 2025-02-20 15:08:14 +01:00
337 changed files with 7638 additions and 13938 deletions

View File

@@ -1 +0,0 @@
EXPO_PUBLIC_WRITE_DEBUG=1

View File

@@ -1 +0,0 @@
EXPO_PUBLIC_WRITE_DEBUG=0

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text

View File

@@ -43,7 +43,6 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.28.0
- 0.27.0
- 0.26.1
- 0.26.0

View File

@@ -1,79 +0,0 @@
name: 🤖 Android APK Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build:
runs-on: ubuntu-24.04
name: 🏗️ Build Android APK
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: ☕ Setup JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
- name: 📦 Install dependencies
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Android dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: |
android/.gradle
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
restore-keys: |
${{ runner.os }}-android-deps-
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🚀 Build APK via Bun
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-apk-${{ github.sha }}-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
retention-days: 7

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

View File

@@ -1,70 +0,0 @@
name: 🤖 iOS IPA Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build:
runs-on: macos-15
name: 🏗️ Build iOS IPA
permissions:
contents: read
steps:
- name: 📥 Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
- name: 📦 Install & Prepare
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🏗 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: 16.7.1
token: ${{ secrets.EXPO_TOKEN }}
- name: 🏗️ Build iOS app
run: |
eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-ipa-${{ github.sha }}-${{ env.DATE_TAG }}
path: |
build-*.ipa
retention-days: 7

View File

@@ -1,46 +0,0 @@
name: 🔒 Lockfile Consistency Check
on:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- name: 🛡️ Verify lockfile consistency
run: |
set -euxo pipefail
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
bun install --frozen-lockfile --dry-run --ignore-scripts
echo "✅ Lockfile is consistent with package.json!"

View File

@@ -1,43 +0,0 @@
name: 🛡️ CodeQL Analysis
on:
push:
branches: [master, develop]
pull_request:
branches: [master, develop]
schedule:
- cron: '24 2 * * *'
jobs:
analyze:
name: 🔎 Analyze with CodeQL
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript' ]
steps:
- name: 📥 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18

View File

@@ -1,24 +0,0 @@
name: 🏷🔀Merge Conflict Labeler
on:
push:
branches: [develop]
pull_request_target:
branches: [develop]
types: [synchronize]
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
runs-on: ubuntu-24.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
with:
dirtyLabel: 'merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
repoToken: '${{ secrets.GITHUB_TOKEN }}'

41
.github/workflows/lint-pr.yaml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: write
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
delete: true

View File

@@ -1,95 +0,0 @@
name: 🚦 Security & Quality Gate
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
permissions:
contents: read
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
delete: true
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
with:
fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- "lint"
- "check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.15'
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile
- name: "🚨 Run ${{ matrix.command }}"
run: bun run ${{ matrix.command }}

39
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Handle Stale Issues
on:
schedule:
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
# Issue specific settings
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: |
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
If this issue is still relevant, please leave a comment to keep it open.
Otherwise, it will be closed in 7 days if no further activity occurs.
Thank you for your contributions!
close-issue-message: |
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
# Pull request settings (disabled)
days-before-pr-stale: -1
days-before-pr-close: -1
# Other settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 100
exempt-issue-labels: "Roadmap v1,help needed,enhancement"

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"
}

View File

@@ -1,23 +0,0 @@
name: 🛎️ Discord Pull Request Notification
on:
pull_request:
types: [opened, reopened]
branches: [develop]
jobs:
notify:
runs-on: ubuntu-24.04
steps:
- name: 🛎️ Notify Discord
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
with:
args: |
📢 New Pull Request in **${{ github.repository }}**
**Title:** ${{ github.event.pull_request.title }}
**By:** ${{ github.event.pull_request.user.login }}
**Branch:** ${{ github.event.pull_request.head.ref }}
🔗 ${{ github.event.pull_request.html_url }}

View File

@@ -1,49 +0,0 @@
name: 🕒 Handle Stale Issues
on:
schedule:
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
- cron: "30 1 * * *"
jobs:
stale-issues:
name: 🗑️ Cleanup Stale Issues
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 500 # Increase if you have >1000 issues
log-level: debug
# Issue configuration
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
# Notifications messages
stale-issue-message: |
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
**Next steps:**
- If this is still relevant, add a comment to keep it open
- Otherwise, it will be closed in 7 days
Thank you for your contributions! 🙌
close-issue-message: |
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
**Need to reopen?**
Click "Reopen" and add a comment explaining why this should stay open.
# Disable PR handling
days-before-pr-stale: -1
days-before-pr-close: -1

9
.gitignore vendored
View File

@@ -10,7 +10,6 @@ npm-debug.*
*.orig.*
web-build/
modules/vlc-player/android/build
modules/vlc-player/android/.gradle
# macOS
.DS_Store
@@ -19,7 +18,9 @@ expo-env.d.ts
Streamyfin.app
build-*
*.mp4
build-*
Streamyfin.app
package-lock.json
@@ -41,8 +42,4 @@ credentials.json
.vscode/
.idea/
.ruby-lsp
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.env
.env.local
*.aab
modules/hls-downloader/android/build

View File

@@ -1 +0,0 @@
lint-staged

17
.vscode/settings.json vendored
View File

@@ -1,24 +1,15 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
}
}

View File

@@ -1,6 +0,0 @@
e2e:
maestro start-device --platform android
maestro test login.yaml
e2e-setup:
curl -fsSL "https://get.maestro.mobile.dev" | bash

View File

@@ -2,7 +2,7 @@
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
<div style="display: flex; flex-direction: row; gap: 8px">
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
@@ -15,11 +15,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming clien
- 🚀 **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.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
## 🧪 Experimental Features
@@ -31,16 +31,16 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
### Chromecast
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
### Streamyfin Plugin
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
- Auto log in to Jellyseerr without the user having to do anything
- Auto log in to Jellyseerr without the user having to do anythin
- Choose the default languages
- Set download method and search provider
- Customize home screen
- Customize homescreen
- And more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
@@ -66,7 +66,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
### Beta testing
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
@@ -86,7 +86,6 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
@@ -118,23 +117,12 @@ If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
## FAQ
1. Q: Why can't I see my libraries in Streamyfin?
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
2. Q: Why can't I see my music library?
A: We don't currently support music and are unlikely to support music in the near future.
## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
## ✨ Acknowledgements
We would like to thank the Jellyfin team for their great software and awesome support on discord.
Special shoutout to the JF official clients for being an inspiration to ours.
### Core Developers
Thanks to the following contributors for their significant contributions:

View File

@@ -1,14 +1,11 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
if (process.env.EXPO_TV != "1") {
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
}
return {
android: {
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
},
...config,
};
};

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.28.0",
"version": "0.27.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -27,33 +27,26 @@
"usesNonExemptEncryption": false
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": {
"dark": "./assets/images/icon-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
}
"bundleIdentifier": "com.fredrikburmester.streamyfin"
},
"android": {
"jsEngine": "hermes",
"versionCode": 56,
"versionCode": 53,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-plain.png",
"monochromeImage": "./assets/images/icon-mono.png",
"backgroundColor": "#464646"
"foregroundImage": "./assets/images/adaptive_icon.png"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
],
"googleServicesFile": "./google-services.json"
]
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-video",
{
@@ -118,7 +111,6 @@
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withRNBackgroundDownloader.js"],
[
"expo-splash-screen",
{
@@ -126,19 +118,6 @@
"image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100
}
],
[
"expo-notifications",
{
"icon": "./assets/images/notification.png",
"color": "#9333EA"
}
],
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
]
],
"experiments": {
@@ -152,7 +131,7 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"owner": "streamyfin",
"owner": "fredrikburmester",
"runtimeVersion": {
"policy": "appVersion"
},

View File

@@ -1,13 +1,13 @@
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import {Stack} from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function CustomMenuLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name='index'
name="index"
options={{
headerShown: true,
headerLargeTitle: true,

View File

@@ -1,13 +1,13 @@
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import Ionicons from "@expo/vector-icons/Ionicons";
import { useAtom } from "jotai/index";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
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 Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next";
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
@@ -26,11 +26,11 @@ export default function menuLinks() {
const getMenuLinks = useCallback(async () => {
try {
const response = await api?.axiosInstance.get(
`${api?.basePath}/web/config.json`,
api?.basePath + "/web/config.json"
);
const config = response?.data;
if (!config && !Object.hasOwn(config, "menuLinks")) {
if (!config && !config.hasOwnProperty("menuLinks")) {
console.error("Menu links not found");
return;
}
@@ -46,7 +46,7 @@ export default function menuLinks() {
}, []);
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 10,
paddingLeft: insets.left,
@@ -63,7 +63,7 @@ export default function menuLinks() {
>
<ListItem
title={item.name}
iconAfter={<Ionicons name='link' size={24} color='white' />}
iconAfter={<Ionicons name="link" size={24} color="white" />}
/>
</TouchableOpacity>
)}
@@ -76,10 +76,8 @@ export default function menuLinks() {
/>
)}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("custom_links.no_links")}
</Text>
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
</View>
}
/>

View File

@@ -1,14 +1,14 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name='index'
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
@@ -17,7 +17,7 @@ export default function SearchLayout() {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>

View File

@@ -18,7 +18,7 @@ export default function favorites() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
@@ -28,7 +28,7 @@ export default function favorites() {
paddingBottom: 16,
}}
>
<View className='my-4'>
<View className="my-4">
<Favorites />
</View>
</ScrollView>

View File

@@ -1,22 +1,17 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
export default function IndexLayout() {
const router = useRouter();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name='index'
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
@@ -25,15 +20,20 @@ export default function IndexLayout() {
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
<View className='flex flex-row items-center space-x-2'>
<View className="flex flex-row items-center space-x-2">
{!Platform.isTV && (
<>
<Chromecast.Chromecast />
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</>
)}
</View>
@@ -41,61 +41,55 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name='downloads/index'
name="downloads/index"
options={{
title: t("home.downloads.downloads_title"),
}}
/>
<Stack.Screen
name='downloads/[seriesId]'
name="downloads/[seriesId]"
options={{
title: t("home.downloads.tvseries"),
}}
/>
<Stack.Screen
name='sessions/index'
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen
name='settings'
name="settings"
options={{
title: t("home.settings.settings_title"),
}}
/>
<Stack.Screen
name='settings/optimized-server/page'
name="settings/optimized-server/page"
options={{
title: "",
}}
/>
<Stack.Screen
name='settings/marlin-search/page'
name="settings/marlin-search/page"
options={{
title: "",
}}
/>
<Stack.Screen
name='settings/jellyseerr/page'
name="settings/jellyseerr/page"
options={{
title: "",
}}
/>
<Stack.Screen
name='settings/hide-libraries/page'
name="settings/hide-libraries/page"
options={{
title: "",
}}
/>
<Stack.Screen
name='settings/logs/page'
name="settings/logs/page"
options={{
title: "",
}}
/>
<Stack.Screen
name='intro/page'
name="intro/page"
options={{
headerShown: false,
title: "",
@@ -106,50 +100,15 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}
const SettingsButton = () => {
const router = useRouter();
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name='settings' color={"white"} size={22} />
</TouchableOpacity>
);
};
const SessionsButton = () => {
const router = useRouter();
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/sessions");
}}
>
<View className='mr-4'>
<Ionicons
name='play-circle'
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}
/>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,16 +1,16 @@
import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
@@ -21,7 +21,7 @@ export default function page() {
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
{}
);
const { downloadedFiles, deleteItems } = useDownload();
@@ -29,9 +29,9 @@ export default function page() {
try {
return (
downloadedFiles
?.filter((f) => f.item.SeriesId === seriesId)
?.filter((f) => f.item.SeriesId == seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
) || []
);
} catch {
@@ -64,7 +64,7 @@ export default function page() {
() =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason],
[groupBySeason]
);
useEffect(() => {
@@ -92,14 +92,14 @@ export default function page() {
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
],
]
);
}, [groupBySeason]);
return (
<View className='flex-1'>
<View className="flex-1">
{series.length > 0 && (
<View className='flex flex-row items-center justify-start my-2 px-4'>
<View className="flex flex-row items-center justify-start my-2 px-4">
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
@@ -112,17 +112,17 @@ export default function page() {
}));
}}
/>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
<Ionicons name="trash" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className='px-4'>
<ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}

View File

@@ -1,28 +1,28 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { t } from 'i18next';
import { DownloadSize } from "@/components/downloads/DownloadSize";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
@@ -45,7 +45,7 @@ export default function page() {
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode",
(f) => f.item.Type === "Episode"
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
@@ -73,22 +73,14 @@ export default function page() {
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_movies_successfully"),
),
)
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
),
)
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
@@ -105,28 +97,26 @@ export default function page() {
paddingBottom: 100,
}}
>
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
</Text>
<Text className='text-xs opacity-70 text-red-600'>
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600">
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col space-y-2 mt-2'>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className='font-semibold'>{q.item.Name}</Text>
<Text className='text-xs opacity-50'>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">
{q.item.Type}
</Text>
</View>
@@ -139,16 +129,14 @@ export default function page() {
});
}}
>
<Ionicons name='close' size={24} color='red' />
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
)}
</View>
)}
@@ -157,19 +145,17 @@ export default function page() {
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className='mb-2 last:mb-0' key={item.item.Id}>
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
@@ -178,22 +164,20 @@ export default function page() {
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
className="mb-2 last:mb-0"
key={items[0].item.SeriesId}
>
<SeriesCard
@@ -207,10 +191,8 @@ export default function page() {
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
<View className="flex px-4">
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
</View>
)}
</View>
@@ -233,14 +215,14 @@ export default function page() {
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
<Button color="purple" onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
<Button color='red' onPress={deleteAllMedia}>
<Button color="red" onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
@@ -266,6 +248,6 @@ function migration_20241124() {
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
],
]
);
}

View File

@@ -15,26 +15,26 @@ export default function page() {
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, []),
}, [])
);
return (
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
<View>
<Text className='text-3xl font-bold text-center mb-2'>
<Text className="text-3xl font-bold text-center mb-2">
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className='text-center'>
<Text className="text-center">
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className='text-lg font-bold'>
<Text className="text-lg font-bold">
{t("home.intro.features_title")}
</Text>
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
<View className='flex flex-row items-center mt-4'>
<Text className="text-xs">{t("home.intro.features_description")}</Text>
<View className="flex flex-row items-center mt-4">
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
@@ -42,70 +42,70 @@ export default function page() {
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Jellyseerr</Text>
<Text className="shrink text-xs">
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
className="flex items-center justify-center"
>
<Ionicons name='cloud-download-outline' size={32} color='white' />
<Ionicons name="cloud-download-outline" size={32} color="white" />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
{t("home.intro.downloads_feature_title")}
</Text>
<Text className='shrink text-xs'>
<Text className="shrink text-xs">
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
className="flex items-center justify-center"
>
<Feather name='cast' size={28} color={"white"} />
<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'>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Chromecast</Text>
<Text className="shrink text-xs">
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
className="flex items-center justify-center"
>
<Feather name='settings' size={28} color={"white"} />
<Feather name="settings" size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<Text className='shrink text-xs'>
<Text className="shrink text-xs">
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className='text-purple-600'
className="text-purple-600"
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
);
}}
>
@@ -120,7 +120,7 @@ export default function page() {
onPress={() => {
router.back();
}}
className='mt-4'
className="mt-4"
>
{t("home.intro.done_button")}
</Button>
@@ -129,9 +129,9 @@ export default function page() {
router.back();
router.push("/settings");
}}
className='mt-4'
className="mt-4"
>
<Text className='text-purple-600 text-center'>
<Text className="text-purple-600 text-center">
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>

View File

@@ -1,556 +0,0 @@
import { Badge } from "@/components/Badge";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import { useInterval } from "@/hooks/useInterval";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
import {
AntDesign,
Entypo,
Ionicons,
MaterialCommunityIcons,
} from "@expo/vector-icons";
import {
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import {
GeneralCommandType,
PlaystateCommand,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { get } from "lodash";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation();
if (isLoading)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!sessions || sessions.length === 0)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("home.sessions.no_active_sessions")}
</Text>
</View>
);
return (
<FlashList
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: 17,
paddingBottom: 150,
}}
data={sessions}
renderItem={({ item }) => <SessionCard session={item} />}
keyExtractor={(item) => item.Id || ""}
estimatedItemSize={200}
/>
);
}
interface SessionCardProps {
session: SessionInfoDto;
}
const SessionCard = ({ session }: SessionCardProps) => {
const api = useAtomValue(apiAtom);
const [remainingTicks, setRemainingTicks] = useState<number>(0);
const tick = () => {
if (session.PlayState?.IsPaused) return;
setRemainingTicks(remainingTicks - 10000000);
};
const getProgressPercentage = () => {
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
return 0;
}
return Math.round(
(100 / session.NowPlayingItem?.RunTimeTicks) *
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
);
};
useEffect(() => {
const currentTime = session.PlayState?.PositionTicks;
const duration = session.NowPlayingItem?.RunTimeTicks;
if (
duration !== null &&
duration !== undefined &&
currentTime !== null &&
currentTime !== undefined
) {
const remainingTimeTicks = duration - currentTime;
setRemainingTicks(remainingTimeTicks);
}
}, [session]);
const { data: ipInfo } = useQuery({
queryKey: ["ipinfo", session.RemoteEndPoint],
cacheTime: Number.POSITIVE_INFINITY,
queryFn: async () => {
const resp = await api.axiosInstance.get(
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
);
return resp.data;
},
});
// Handle session controls
const [isControlLoading, setIsControlLoading] = useState<
Record<string, boolean>
>({});
const handleSystemCommand = async (command: GeneralCommandType) => {
if (!api || !session.Id) return false;
setIsControlLoading({ ...isControlLoading, [command]: true });
try {
getSessionApi(api).sendSystemCommand({
sessionId: session.Id,
command,
});
return true;
} catch (error) {
console.error(`Error sending ${command} command:`, error);
return false;
} finally {
setIsControlLoading({ ...isControlLoading, [command]: false });
}
};
const handlePlaystateCommand = async (command: PlaystateCommand) => {
if (!api || !session.Id) return false;
setIsControlLoading({ ...isControlLoading, [command]: true });
try {
getSessionApi(api).sendPlaystateCommand({
sessionId: session.Id,
command,
});
return true;
} catch (error) {
console.error(`Error sending playstate ${command} command:`, error);
return false;
} finally {
setIsControlLoading({ ...isControlLoading, [command]: false });
}
};
const handlePlayPause = async () => {
console.log("handlePlayPause");
await handlePlaystateCommand(PlaystateCommand.PlayPause);
};
const handleStop = async () => {
await handlePlaystateCommand(PlaystateCommand.Stop);
};
const handlePrevious = async () => {
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
};
const handleNext = async () => {
await handlePlaystateCommand(PlaystateCommand.NextTrack);
};
const handleToggleMute = async () => {
await handleSystemCommand(GeneralCommandType.ToggleMute);
};
const handleVolumeUp = async () => {
await handleSystemCommand(GeneralCommandType.VolumeUp);
};
const handleVolumeDown = async () => {
await handleSystemCommand(GeneralCommandType.VolumeDown);
};
useInterval(tick, 1000);
return (
<View className='flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4'>
<View className='flex flex-row p-4'>
<View className='w-20 pr-4'>
<Poster
id={session.NowPlayingItem?.Id}
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
/>
</View>
<View className='w-full flex-1'>
<View className='flex flex-row justify-between'>
<View className='flex-1 pr-4'>
{session.NowPlayingItem?.Type === "Episode" ? (
<>
<Text className='font-bold'>
{session.NowPlayingItem?.Name}
</Text>
<Text numberOfLines={1} className='text-xs opacity-50'>
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
{" - "}
{session.NowPlayingItem.SeriesName}
</Text>
</>
) : (
<>
<Text className='font-bold'>
{session.NowPlayingItem?.Name}
</Text>
<Text className='text-xs opacity-50'>
{session.NowPlayingItem?.ProductionYear}
</Text>
<Text className='text-xs opacity-50'>
{session.NowPlayingItem?.SeriesName}
</Text>
</>
)}
</View>
<Text className='text-xs opacity-50 align-right text-right'>
{session.UserName}
{"\n"}
{session.Client}
{"\n"}
{session.DeviceName}
{"\n"}
{ipInfo?.cityName} {ipInfo?.countryCode}
</Text>
</View>
<View className='flex-1' />
<View className='flex flex-col align-bottom'>
<View className='flex flex-row justify-between align-bottom mb-1'>
<Text className='-ml-0.5 text-xs opacity-50 align-left text-left'>
{!session.PlayState?.IsPaused ? (
<Ionicons name='play' size={14} color='white' />
) : (
<Ionicons name='pause' size={14} color='white' />
)}
</Text>
<Text className='text-xs opacity-50 align-right text-right'>
{formatTimeString(remainingTicks, "tick")} left
</Text>
</View>
<View className='align-bottom bg-gray-800 h-1'>
<View
className={"bg-purple-600 h-full"}
style={{
width: `${getProgressPercentage()}%`,
}}
/>
</View>
{/* Session controls */}
<View className='flex flex-row mt-2 space-x-4 justify-center'>
<TouchableOpacity
onPress={handlePrevious}
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
style={{
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
? 0.5
: 1,
}}
>
<MaterialCommunityIcons
name='skip-previous'
size={24}
color='white'
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handlePlayPause}
disabled={isControlLoading[PlaystateCommand.PlayPause]}
style={{
opacity: isControlLoading[PlaystateCommand.PlayPause]
? 0.5
: 1,
}}
>
{session.PlayState?.IsPaused ? (
<Ionicons name='play' size={24} color='white' />
) : (
<Ionicons name='pause' size={24} color='white' />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleStop}
disabled={isControlLoading[PlaystateCommand.Stop]}
style={{
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
}}
>
<Ionicons name='stop' size={24} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={handleNext}
disabled={isControlLoading[PlaystateCommand.NextTrack]}
style={{
opacity: isControlLoading[PlaystateCommand.NextTrack]
? 0.5
: 1,
}}
>
<MaterialCommunityIcons
name='skip-next'
size={24}
color='white'
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleVolumeDown}
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
style={{
opacity: isControlLoading[GeneralCommandType.VolumeDown]
? 0.5
: 1,
}}
>
<Ionicons name='volume-low' size={24} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={handleToggleMute}
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
style={{
opacity: isControlLoading[GeneralCommandType.ToggleMute]
? 0.5
: 1,
}}
>
<Ionicons
name='volume-mute'
size={24}
color={session.PlayState?.IsMuted ? "red" : "white"}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleVolumeUp}
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
style={{
opacity: isControlLoading[GeneralCommandType.VolumeUp]
? 0.5
: 1,
}}
>
<Ionicons name='volume-high' size={24} color='white' />
</TouchableOpacity>
</View>
</View>
</View>
</View>
<TranscodingView session={session} />
</View>
);
};
interface TranscodingBadgesProps {
properties: StreamProps;
}
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
const iconMap = {
bitrate: <Ionicons name='speedometer-outline' size={12} color='white' />,
codec: <Ionicons name='layers-outline' size={12} color='white' />,
videoRange: (
<Ionicons name='color-palette-outline' size={12} color='white' />
),
resolution: <Ionicons name='film-outline' size={12} color='white' />,
language: <Ionicons name='language-outline' size={12} color='white' />,
audioChannels: <Ionicons name='mic-outline' size={12} color='white' />,
hwType: <Ionicons name='hardware-chip-outline' size={12} color='white' />,
} as const;
const icon = (val: string) => {
return (
iconMap[val as keyof typeof iconMap] ?? (
<Ionicons name='layers-outline' size={12} color='white' />
)
);
};
const formatVal = (key: string, val: any) => {
switch (key) {
case "bitrate":
return formatBitrate(val);
case "hwType":
return val === HardwareAccelerationType.None ? "sw" : "hw";
default:
return val;
}
};
return Object.entries(properties)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key]) => (
<Badge
key={key}
variant='gray'
className='m-0 p-0 pt-0.5 mr-1'
text={formatVal(key, properties[key as keyof StreamProps])}
iconLeft={icon(key)}
/>
));
};
interface StreamProps {
hwType?: HardwareAccelerationType | null | undefined;
resolution?: string | null | undefined;
language?: string | null | undefined;
codec?: string | null | undefined;
bitrate?: number | null | undefined;
videoRange?: string | null | undefined;
audioChannels?: string | null | undefined;
}
interface TranscodingStreamViewProps {
title: string | undefined;
value?: string;
isTranscoding: boolean;
transcodeValue?: string | undefined | null;
properties: StreamProps;
transcodeProperties?: StreamProps;
}
const TranscodingStreamView = ({
title,
isTranscoding,
properties,
transcodeProperties,
value,
transcodeValue,
}: TranscodingStreamViewProps) => {
return (
<View className='flex flex-col pt-2 first:pt-0'>
<View className='flex flex-row'>
<Text className='text-xs opacity-50 w-20 font-bold text-right pr-4'>
{title}
</Text>
<Text className='flex-1'>
<TranscodingBadges properties={properties} />
</Text>
</View>
{isTranscoding && transcodeProperties ? (
<>
<View className='flex flex-row'>
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
<MaterialCommunityIcons
name='arrow-right-bottom'
size={14}
color='white'
/>
</Text>
<Text className='flex-1 text-sm mt-1'>
<TranscodingBadges properties={transcodeProperties} />
</Text>
</View>
</>
) : null}
</View>
);
};
const TranscodingView = ({ session }: SessionCardProps) => {
const videoStream = useMemo(() => {
return session.NowPlayingItem?.MediaStreams?.filter(
(s) => s.Type === "Video",
)[0];
}, [session]);
const audioStream = useMemo(() => {
const index = session.PlayState?.AudioStreamIndex;
return index !== null && index !== undefined
? session.NowPlayingItem?.MediaStreams?.[index]
: undefined;
}, [session.PlayState?.AudioStreamIndex]);
const subtitleStream = useMemo(() => {
const index = session.PlayState?.SubtitleStreamIndex;
return index !== null && index !== undefined
? session.NowPlayingItem?.MediaStreams?.[index]
: undefined;
}, [session.PlayState?.SubtitleStreamIndex]);
const isTranscoding = useMemo(() => {
return (
session.PlayState?.PlayMethod === "Transcode" && session.TranscodingInfo
);
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
const videoStreamTitle = () => {
return videoStream?.DisplayTitle?.split(" ")[0];
};
return (
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
<TranscodingStreamView
title='Video'
properties={{
resolution: videoStreamTitle(),
bitrate: videoStream?.BitRate,
codec: videoStream?.Codec,
}}
transcodeProperties={{
hwType: session.TranscodingInfo?.HardwareAccelerationType,
bitrate: session.TranscodingInfo?.Bitrate,
codec: session.TranscodingInfo?.VideoCodec,
}}
isTranscoding={
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
}
/>
<TranscodingStreamView
title='Audio'
properties={{
language: audioStream?.Language,
bitrate: audioStream?.BitRate,
codec: audioStream?.Codec,
audioChannels: audioStream?.ChannelLayout,
}}
transcodeProperties={{
codec: session.TranscodingInfo?.AudioCodec,
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
}}
isTranscoding={
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
}
/>
{subtitleStream && (
<TranscodingStreamView
title='Subtitle'
isTranscoding={false}
properties={{
language: subtitleStream?.Language,
codec: subtitleStream?.Codec,
}}
transcodeValue={null}
/>
)}
</View>
);
};

View File

@@ -3,7 +3,6 @@ import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
@@ -15,20 +14,27 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import React, { useCallback, useEffect, useMemo } from "react";
import {
ScrollView,
StyleSheet,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as TaskManager from "expo-task-manager";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { RecentlyAddedNotificationsSettings } from "@/components/settings/RecentlyAddedNotifications";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
@@ -46,7 +52,7 @@ export default function settings() {
logout();
}}
>
<Text className='text-red-600'>
<Text className="text-red-600">
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>
@@ -61,15 +67,14 @@ export default function settings() {
paddingRight: insets.right,
}}
>
<View className='p-4 flex flex-col gap-y-4'>
<View className="p-4 flex flex-col gap-y-4">
<UserInfo />
<QuickConnect className='mb-4' />
<QuickConnect className="mb-4" />
<MediaProvider>
<MediaToggles className='mb-4' />
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
<MediaToggles className="mb-4" />
<AudioToggles className="mb-4" />
<SubtitleToggles className="mb-4" />
</MediaProvider>
<OtherSettings />
@@ -80,8 +85,6 @@ export default function settings() {
<AppLanguageSelector />
<ChromecastSettings />
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
@@ -90,7 +93,7 @@ export default function settings() {
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
textColor="red"
onPress={() => {
storage.set("hasShownIntro", false);
}}
@@ -98,7 +101,7 @@ export default function settings() {
/>
</ListGroup>
<View className='mb-4'>
<View className="">
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
@@ -106,14 +109,28 @@ export default function settings() {
title={t("home.settings.logs.logs_title")}
/>
<ListItem
textColor='red'
textColor="red"
onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")}
/>
</ListGroup>
</View>
<StorageSettings />
<RecentlyAddedNotificationsSettings />
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: "white",
overflow: "hidden",
marginVertical: 16,
opacity: 0.3,
}}
></View>
<View className="">
<StorageSettings />
</View>
</View>
</ScrollView>
);

View File

@@ -1,15 +1,15 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
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 { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
@@ -18,7 +18,7 @@ export default function page() {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
@@ -33,7 +33,7 @@ export default function page() {
if (isLoading)
return (
<View className='mt-4'>
<View className="mt-4">
<Loader />
</View>
);
@@ -41,7 +41,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4'
className="px-4"
>
<ListGroup>
{data?.map((view) => (
@@ -59,8 +59,8 @@ export default function page() {
</ListItem>
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -1,6 +1,6 @@
import DisabledSetting from "@/components/settings/DisabledSetting";
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();
@@ -8,7 +8,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
className="p-4"
>
<JellyseerrSettings />
</DisabledSetting>

View File

@@ -1,157 +1,35 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
export default function page() {
const navigation = useNavigation();
const { logs } = useLog();
const { t } = useTranslation();
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
const codeBlockStyle = {
backgroundColor: "#000",
padding: 10,
fontFamily: "monospace",
maxHeight: 300,
};
const [loading, setLoading] = useState<boolean>(false);
const [state, setState] = useState<Record<string, boolean>>({});
const [order, setOrder] = useState<"asc" | "desc">("desc");
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
const filteredLogs = useMemo(
() =>
logs
?.filter((log) => levels.includes(log.level))
?.[
// Already in asc order as they are recorded. just reverse for desc
order === "desc" ? "reverse" : "concat"
]?.(),
[logs, order, levels],
);
// Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => {
const uri = `${FileSystem.documentDirectory}logs.txt`;
setLoading(true);
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
.then(() => {
setLoading(false);
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
})
.catch((e) =>
writeErrorLog("Something went wrong attempting to export", e),
)
.finally(() => setLoading(false));
}, [filteredLogs]);
useEffect(() => {
navigation.setOptions({
headerRight: () =>
loading ? (
<Loader />
) : (
<TouchableOpacity onPress={share}>
<Text>{t("home.settings.logs.export_logs")}</Text>
</TouchableOpacity>
),
});
}, [share, loading]);
return (
<>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton
id='order'
queryKey='log'
queryFn={async () => ["asc", "desc"]}
set={(values) => setOrder(values[0])}
values={[order]}
title={t("library.filters.sort_order")}
renderItemLabel={(order) => t(`library.filters.${order}`)}
showSearch={false}
/>
<FilterButton
id='levels'
queryKey='log'
queryFn={async () => defaultLevels}
set={setLevels}
values={levels}
title={t("home.settings.logs.level")}
renderItemLabel={(level) => level}
showSearch={false}
multiple={true}
/>
</View>
<ScrollView className='pb-4 px-4'>
<View className='flex flex-col space-y-2'>
{filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
<TouchableOpacity
disabled={!log.data}
onPress={() =>
setState((v) => ({
...v,
[log.timestamp]: !v[log.timestamp],
}))
}
>
<View className='flex flex-row justify-between'>
<Text
className={`mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
${log.level === "DEBUG" && "text-purple-500"}
`}
>
{log.level}
</Text>
<Text className='text-xs'>
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text uiTextView selectable className='text-xs'>
{log.message}
</Text>
</TouchableOpacity>
{log.data && (
<>
{!state[log.timestamp] && (
<Text className='text-xs mt-0.5'>
{t("home.settings.logs.click_for_more_info")}
</Text>
)}
<Collapsible collapsed={!state[log.timestamp]}>
<View className='mt-2 flex flex-col space-y-2'>
<ScrollView className='rounded-xl' style={codeBlockStyle}>
<Text>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>
</View>
</Collapsible>
</>
)}
</View>
))}
{filteredLogs?.length === 0 && (
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
<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>
)}
</View>
</ScrollView>
</>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
)}
</View>
</ScrollView>
);
}

View File

@@ -6,8 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { useEffect, useMemo, useState } from "react";
import React, {useEffect, useMemo, useState} from "react";
import {
Linking,
Switch,
@@ -16,6 +15,7 @@ import {
View,
} from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
@@ -39,10 +39,7 @@ export default function page() {
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
}, [pluginSettings]);
useEffect(() => {
@@ -50,9 +47,7 @@ export default function page() {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
</TouchableOpacity>
),
});
@@ -62,16 +57,17 @@ export default function page() {
if (!settings) return null;
return (
<DisabledSetting disabled={disabled} className='px-4'>
<DisabledSetting
disabled={disabled}
className="px-4"
>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -91,30 +87,28 @@ export default function page() {
<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'
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
>
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
className="text-white"
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
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'>
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
<Text className="text-blue-500" onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>

View File

@@ -1,5 +1,4 @@
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -9,9 +8,10 @@ import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
@@ -31,14 +31,14 @@ export default function page() {
return;
}
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
updateSettings({
optimizedVersionsServerUrl: updatedUrl,
});
return await getStatistics({
url: updatedUrl,
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(),
});
@@ -67,12 +67,8 @@ export default function page() {
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity
onPress={() => onSave(optimizedVersionsServerUrl)}
>
<Text className='text-blue-500'>
{t("home.settings.downloads.save_button")}
</Text>
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
</TouchableOpacity>
),
});
@@ -82,7 +78,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className='p-4'
className="p-4"
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}

View File

@@ -10,15 +10,15 @@ import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data;
},
[api, user?.Id, actorId],
[api, user?.Id, actorId]
);
const backdropUrl = useMemo(
@@ -79,12 +79,12 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item],
[item]
);
if (l1)
return (
<View className='justify-center items-center h-full'>
<View className="justify-center items-center h-full">
<Loader />
</View>
);
@@ -105,13 +105,13 @@ const page: React.FC = () => {
/>
}
>
<View className='flex flex-col space-y-4 my-4'>
<View className='px-4 mb-4'>
<MoviesTitleHeader item={item} className='mb-4' />
<View className="flex flex-col space-y-4 my-4">
<View className="px-4 mb-4">
<MoviesTitleHeader item={item} className="mb-4" />
<OverviewText text={item.Overview} />
</View>
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
{t("item_card.appeared_in")}
</Text>
<InfiniteHorizontalScroll
@@ -133,7 +133,7 @@ const page: React.FC = () => {
queryFn={fetchItems}
queryKey={["actor", "movies", actorId]}
/>
<View className='h-12' />
<View className="h-12"></View>
</View>
</ParallaxScrollView>
);

View File

@@ -1,23 +1,22 @@
import { ItemCardText } from "@/components/ItemCardText";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
sortByAtom,
SortByOption,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type {
import {
BaseItemDto,
BaseItemDtoQueryResult,
ItemSortBy,
@@ -30,11 +29,11 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -44,7 +43,7 @@ const page: React.FC = () => {
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP,
ScreenOrientation.Orientation.PORTRAIT_UP
);
const { t } = useTranslation();
@@ -112,7 +111,7 @@ const page: React.FC = () => {
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)),
years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: ["Movie", "Series"],
});
@@ -127,7 +126,7 @@ const page: React.FC = () => {
selectedTags,
sortBy,
sortOrder,
],
]
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
@@ -152,13 +151,14 @@ const page: React.FC = () => {
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
0
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
@@ -188,8 +188,8 @@ const page: React.FC = () => {
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
? "flex-start"
: "center",
width: "89%",
}}
>
@@ -199,14 +199,14 @@ const page: React.FC = () => {
</View>
</TouchableItemRouter>
),
[orientation],
[orientation]
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className=''>
<View className="">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
@@ -232,13 +232,13 @@ const page: React.FC = () => {
key: "genre",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='genreFilter'
className="mr-1"
collectionId={collectionId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -259,13 +259,13 @@ const page: React.FC = () => {
key: "year",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='yearFilter'
className="mr-1"
collectionId={collectionId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -284,13 +284,13 @@ const page: React.FC = () => {
key: "tags",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='tagsFilter'
className="mr-1"
collectionId={collectionId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -311,9 +311,9 @@ const page: React.FC = () => {
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortBy'
className="mr-1"
collectionId={collectionId}
queryKey="sortBy"
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
@@ -331,9 +331,9 @@ const page: React.FC = () => {
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortOrder'
className="mr-1"
collectionId={collectionId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
@@ -368,7 +368,7 @@ const page: React.FC = () => {
sortOrder,
setSortOrder,
isFetching,
],
]
);
if (!collection) return null;
@@ -376,10 +376,8 @@ const page: React.FC = () => {
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
</View>
}
extraData={[
@@ -389,7 +387,7 @@ const page: React.FC = () => {
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
@@ -411,7 +409,7 @@ const page: React.FC = () => {
width: 10,
height: 10,
}}
/>
></View>
)}
/>
);

View File

@@ -1,13 +1,11 @@
import { ItemContent } from "@/components/ItemContent";
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import React, { useEffect } from "react";
import { View } from "react-native";
import Animated, {
runOnJS,
@@ -15,6 +13,7 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useTranslation } from "react-i18next";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
@@ -76,36 +75,36 @@ const Page: React.FC = () => {
if (isError)
return (
<View className='flex flex-col items-center justify-center h-screen w-screen'>
<View className="flex flex-col items-center justify-center h-screen w-screen">
<Text>{t("item_card.could_not_load_item")}</Text>
</View>
);
return (
<View className='flex flex-1 relative'>
<View className="flex flex-1 relative">
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
className="bg-transparent rounded-lg mb-4 w-full"
></View>
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
<View className="flex flex-row space-x-1 mb-8">
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
</Animated.View>
{item && <ItemContent item={item} />}
</View>

View File

@@ -1,45 +1,45 @@
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";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
Results,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr();
const {jellyseerrApi} = useJellyseerr();
const { companyId, name, image, type } = local as unknown as {
companyId: string;
name: string;
image: string;
type: DiscoverSliderType;
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
image: string,
type: DiscoverSliderType
};
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({ pageParam }) => {
const params: any = {
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,
);
(
type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
) + `/${companyId}`,
params
)
},
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
@@ -50,58 +50,46 @@ export default function page() {
});
const flatData = useMemo(
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
"id",
) ?? [],
[data],
() => 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],
() => 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=''
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
fetchNextPage()
}
}}
logo={
<Image
id={companyId}
key={companyId}
className='bottom-1 w-1/2'
className="bottom-1 w-1/2"
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit='contain'
contentFit="contain"
style={{
aspectRatio: "4/3",
}}
/>
}
renderItem={(item, index) => (
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
}
/>
);
}

View File

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

View File

@@ -1,68 +1,66 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import { Text } from "@/components/common/Text";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
type IssueType,
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const router = useRouter();
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
mediaType: MediaType;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
} & Partial<MovieResult | TvResult>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -73,7 +71,7 @@ const Page: React.FC = () => {
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id],
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
@@ -81,9 +79,9 @@ const Page: React.FC = () => {
retryOnMount: true,
refetchInterval: 0,
queryFn: async () => {
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!);
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!);
},
});
@@ -98,7 +96,7 @@ const Page: React.FC = () => {
appearsOnIndex={0}
/>
),
[],
[]
);
const submitIssue = useCallback(() => {
@@ -113,18 +111,10 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
_setRequestBody(body);
advancedReqModalRef?.current?.present?.();
},
[requestBody, _setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: mediaType!,
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -132,7 +122,7 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
setRequestBody(body);
advancedReqModalRef?.current?.present?.(body);
return;
}
@@ -142,15 +132,15 @@ const Page: React.FC = () => {
const isAnime = useMemo(
() =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
mediaType === MediaType.TV,
[details],
result.mediaType === MediaType.TV,
[details]
);
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<ItemActions item={details} />
</TouchableOpacity>
),
@@ -160,14 +150,14 @@ const Page: React.FC = () => {
return (
<View
className='flex-1 relative'
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className='flex-1 opacity-100'
className="flex-1 opacity-100"
headerHeight={300}
headerImage={
<View>
@@ -182,7 +172,7 @@ const Page: React.FC = () => {
source={{
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces",
"w1920_and_h800_multi_faces"
),
}}
/>
@@ -192,12 +182,12 @@ const Page: React.FC = () => {
width: "100%",
height: "100%",
}}
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
>
<Ionicons
name='image-outline'
name="image-outline"
size={24}
color='white'
color="white"
style={{ opacity: 0.4 }}
/>
</View>
@@ -205,31 +195,23 @@ const Page: React.FC = () => {
</View>
}
>
<View className='flex flex-col'>
<View className='space-y-4'>
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
<JellyserrRatings
result={
result as
| MovieResult
| TvResult
| MovieDetails
| TvDetails
}
/>
<View className="flex flex-col">
<View className="space-y-4">
<View className="px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-56">
<JellyserrRatings result={result as MovieResult | TvResult} />
<Text
uiTextView
selectable
className='font-bold text-2xl mb-1'
className="font-bold text-2xl mb-1"
>
{mediaTitle}
</Text>
<Text className='opacity-50'>{releaseYear}</Text>
<Text className="opacity-50">{releaseYear}</Text>
</View>
<Image
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
cachePolicy={"memory-disk"}
transition={300}
source={{
@@ -237,80 +219,48 @@ const Page: React.FC = () => {
}}
/>
</View>
<View>
<View className="mb-4">
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
<Button loading={true} disabled={true} color="purple"></Button>
) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'>
<Button color="purple" onPress={request}>
{t("jellyseerr.request_button")}
</Button>
) : (
details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'>
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons
name='warning-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
// @ts-expect-error
router.push(url);
}}
iconLeft={
<Ionicons name='play-outline' size={20} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>Play</Text>
</Button>
</View>
)
<Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
color="transparent"
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white" />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
{t("jellyseerr.report_issue_button")}
</Button>
)}
<OverviewText text={result.overview} className='mt-4' />
<OverviewText text={result.overview} className="mt-4" />
</View>
{mediaType === MediaType.TV && (
{result.mediaType === MediaType.TV && (
<JellyseerrSeasons
isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) => setRequestBody(data)}
onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
}
/>
)}
<DetailFacts
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
details={details}
/>
<Cast details={details} />
@@ -319,17 +269,14 @@ const Page: React.FC = () => {
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
requestBody={requestBody}
title={mediaTitle}
id={result.id!}
type={mediaType}
id={result.id!!}
type={result.mediaType as MediaType}
isAnime={isAnime}
onRequested={() => {
_setRequestBody(undefined);
advancedReqModalRef?.current?.close();
refetch();
}}
onDismiss={() => _setRequestBody(undefined)}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -343,22 +290,22 @@ const Page: React.FC = () => {
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className='font-bold text-2xl text-neutral-100'>
<Text className="font-bold text-2xl text-neutral-100">
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<View className="flex flex-col space-y-2 items-start">
<View className="flex flex-col">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
@@ -368,8 +315,8 @@ const Page: React.FC = () => {
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
@@ -396,14 +343,14 @@ const Page: React.FC = () => {
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
clearButtonMode="always"
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
@@ -411,7 +358,7 @@ const Page: React.FC = () => {
/>
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
<Button className="mt-auto" onPress={submitIssue} color="purple">
{t("jellyseerr.submit_button")}
</Button>
</View>

View File

@@ -1,30 +1,25 @@
import { OverviewText } from "@/components/OverviewText";
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 { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useSegments } from "expo-router";
import { orderBy, uniqBy } from "lodash";
import React, { useMemo } from "react";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { useTranslation } from "react-i18next";
export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const {
jellyseerrApi,
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { personId } = local as { personId: string };
@@ -39,27 +34,18 @@ export default function page() {
const castedRoles: PersonCreditCast[] = useMemo(
() =>
uniqBy(
orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc",
),
"id",
),
[data?.combinedCredits],
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],
() => jellyseerrApi
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, data?.combinedCredits]
);
return (
@@ -72,15 +58,15 @@ export default function page() {
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className='rounded-full bottom-1'
className="rounded-full bottom-1"
source={{
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2",
"w600_and_h600_bestv2"
),
}}
cachePolicy={"memory-disk"}
contentFit='cover'
contentFit="cover"
style={{
width: 125,
height: 125,
@@ -89,27 +75,27 @@ export default function page() {
}
HeaderContent={() => (
<>
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'>
<Text className="font-bold text-2xl mb-1">
{data?.details?.name}
</Text>
<Text className="opacity-50">
{t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!).toLocaleDateString(
{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} />
<OverviewText text={data?.details?.biography} className="mt-4" />
)}
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
/>
);
}

View File

@@ -3,10 +3,7 @@ import type {
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
import React from "react";
@@ -24,8 +21,8 @@ const Layout = () => {
<>
<Stack.Screen options={{ title: "Live TV" }} />
<Tab
initialRouteName='programs'
keyboardDismissMode='none'
initialRouteName="programs"
keyboardDismissMode="none"
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: { fontSize: 10 },
@@ -40,10 +37,10 @@ const Layout = () => {
tabBarScrollEnabled: true,
}}
>
<Tab.Screen name='programs' />
<Tab.Screen name='guide' />
<Tab.Screen name='channels' />
<Tab.Screen name='recordings' />
<Tab.Screen name="programs" />
<Tab.Screen name="guide" />
<Tab.Screen name="channels" />
<Tab.Screen name="recordings" />
</Tab>
</>
);

View File

@@ -31,13 +31,13 @@ export default function page() {
});
return (
<View className='flex flex-1'>
<View className="flex flex-1">
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className='flex flex-row items-center px-4 mb-2'>
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
<View className="flex flex-row items-center px-4 mb-2">
<View className="w-22 mr-4 rounded-lg overflow-hidden">
<ItemImage
style={{
aspectRatio: "1/1",
@@ -47,7 +47,7 @@ export default function page() {
item={item}
/>
</View>
<Text className='font-bold'>{item.Name}</Text>
<Text className="font-bold">{item.Name}</Text>
</View>
)}
/>

View File

@@ -9,7 +9,6 @@ import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Dimensions,
@@ -18,6 +17,7 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20;
@@ -71,7 +71,7 @@ export default function page() {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
Boolean,
Boolean
) as string[],
ImageTypeLimit: 1,
EnableImages: false,
@@ -100,7 +100,7 @@ export default function page() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior="automatic"
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
@@ -117,16 +117,16 @@ export default function page() {
}
/>
<View className='flex flex-row'>
<View className='flex flex-col w-[64px]'>
<View className="flex flex-row">
<View className="flex flex-col w-[64px]">
<View
style={{
height: HOUR_HEIGHT,
}}
className='bg-neutral-800'
/>
className="bg-neutral-800"
></View>
{channels?.Items?.map((c, i) => (
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
<ItemImage
style={{
width: "100%",
@@ -148,7 +148,7 @@ export default function page() {
setScrollX(e.nativeEvent.contentOffset.x);
}}
>
<View className='flex flex-col'>
<View className="flex flex-col">
<HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, i) => (
<MemoizedLiveTVGuideRow
@@ -180,14 +180,14 @@ const PageButtons: React.FC<PageButtonsProps> = ({
}) => {
const { t } = useTranslation();
return (
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<TouchableOpacity
onPress={onPrevPage}
disabled={currentPage === 1}
className='flex flex-row items-center'
className="flex flex-row items-center"
>
<Ionicons
name='chevron-back'
name="chevron-back"
size={24}
color={currentPage === 1 ? "gray" : "white"}
/>
@@ -199,11 +199,11 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.previous")}
</Text>
</TouchableOpacity>
<Text className='text-white'>Page {currentPage}</Text>
<Text className="text-white">Page {currentPage}</Text>
<TouchableOpacity
onPress={onNextPage}
disabled={isNextDisabled}
className='flex flex-row items-center'
className="flex flex-row items-center"
>
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
@@ -211,7 +211,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.next")}
</Text>
<Ionicons
name='chevron-forward'
name="chevron-forward"
size={24}
color={isNextDisabled ? "gray" : "white"}
/>

View File

@@ -1,13 +1,13 @@
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function page() {
const [api] = useAtom(apiAtom);
@@ -19,7 +19,7 @@ export default function page() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior="automatic"
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
@@ -28,7 +28,7 @@ export default function page() {
paddingTop: 8,
}}
>
<View className='flex flex-col space-y-2'>
<View className="flex flex-col space-y-2">
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={t("live_tv.on_now")}
@@ -45,7 +45,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation='horizontal'
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "shows"]}
@@ -67,7 +67,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation='horizontal'
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "movies"]}
@@ -85,7 +85,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation='horizontal'
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "sports"]}
@@ -103,7 +103,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation='horizontal'
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "kids"]}
@@ -121,7 +121,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation='horizontal'
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "news"]}
@@ -139,7 +139,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation='horizontal'
orientation="horizontal"
/>
</View>
</ScrollView>

View File

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

View File

@@ -14,10 +14,9 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useEffect, useMemo } from "react";
import React, { useEffect, useMemo } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
const page: React.FC = () => {
const navigation = useNavigation();
@@ -50,7 +49,7 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item],
[item]
);
const logoUrl = useMemo(
@@ -59,7 +58,7 @@ const page: React.FC = () => {
api,
item,
}),
[item],
[item]
);
const { data: allEpisodes, isLoading } = useQuery({
@@ -84,25 +83,23 @@ const page: React.FC = () => {
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
<View className="flex flex-row items-center space-x-2">
<AddToFavorites item={item} type="series" />
<DownloadItems
size="large"
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons
name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/>
</View>
),
});
@@ -125,23 +122,25 @@ const page: React.FC = () => {
/>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className='flex flex-col pt-4'>
<View className="flex flex-col pt-4">
<SeriesHeader item={item} />
<View className='mb-4'>
<View className="mb-4">
<NextUp seriesId={seriesId} />
</View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />

View File

@@ -1,35 +1,35 @@
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, View, useWindowDimensions } from "react-native";
import { FlatList, useWindowDimensions, View } from "react-native";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
sortByAtom,
SortByOption,
sortByPreferenceAtom,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type {
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
@@ -40,8 +40,8 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -58,7 +58,7 @@ const Page = () => {
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom,
sortOrderPreferenceAtom
);
const { orientation } = useOrientation();
@@ -88,7 +88,7 @@ const Page = () => {
}
_setSortBy(sortBy);
},
[libraryId, sortByPreference],
[libraryId, sortByPreference]
);
const setSortOrder = useCallback(
@@ -102,7 +102,7 @@ const Page = () => {
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference],
[libraryId, sortOrderPreference]
);
const nrOfCols = useMemo(() => {
@@ -169,7 +169,7 @@ const Page = () => {
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)),
years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined,
});
@@ -185,7 +185,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
],
]
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
@@ -211,13 +211,14 @@ const Page = () => {
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
0
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!library,
@@ -247,8 +248,8 @@ const Page = () => {
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
? "flex-start"
: "center"
: "center",
width: "89%",
}}
@@ -259,14 +260,14 @@ const Page = () => {
</View>
</TouchableItemRouter>
),
[orientation],
[orientation]
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className=''>
<View className="">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
@@ -285,13 +286,13 @@ const Page = () => {
key: "genre",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='genreFilter'
className="mr-1"
collectionId={libraryId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -312,13 +313,13 @@ const Page = () => {
key: "year",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='yearFilter'
className="mr-1"
collectionId={libraryId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -337,13 +338,13 @@ const Page = () => {
key: "tags",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='tagsFilter'
className="mr-1"
collectionId={libraryId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -364,18 +365,10 @@ const Page = () => {
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortBy'
queryFn={async () =>
sortOptions
.filter(
(s) =>
library?.CollectionType !== "movies" ||
s.key !== SortByOption.DateLastContentAdded,
)
.map((s) => s.key)
}
className="mr-1"
collectionId={libraryId}
queryKey="sortBy"
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
@@ -392,9 +385,9 @@ const Page = () => {
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortOrder'
className="mr-1"
collectionId={libraryId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
@@ -429,29 +422,34 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
],
]
);
const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading)
return (
<View className='w-full h-full flex items-center justify-center'>
<View className="w-full h-full flex items-center justify-center">
<Loader />
</View>
);
if (flatData.length === 0)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
</View>
);
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
@@ -476,7 +474,7 @@ const Page = () => {
width: 10,
height: 10,
}}
/>
></View>
)}
/>
);

View File

@@ -16,7 +16,7 @@ export default function IndexLayout() {
return (
<Stack>
<Stack.Screen
name='index'
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
@@ -25,7 +25,7 @@ export default function IndexLayout() {
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () =>
!pluginSettings?.libraryOptions?.locked &&
@@ -33,9 +33,9 @@ export default function IndexLayout() {
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
name='ellipsis-horizontal-outline'
name="ellipsis-horizontal-outline"
size={24}
color='white'
color="white"
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -50,9 +50,9 @@ export default function IndexLayout() {
<DropdownMenu.Label>
{t("library.options.display")}
</DropdownMenu.Label>
<DropdownMenu.Group key='display-group'>
<DropdownMenu.Group key="display-group">
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
<DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
@@ -63,7 +63,7 @@ export default function IndexLayout() {
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='display-option-1'
key="display-option-1"
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
@@ -75,12 +75,12 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-1'>
<DropdownMenu.ItemTitle key="display-title-1">
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='display-option-2'
key="display-option-2"
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
@@ -92,14 +92,14 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-2'>
<DropdownMenu.ItemTitle key="display-title-2">
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
<DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
@@ -110,7 +110,7 @@ export default function IndexLayout() {
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='poster-option'
key="poster-option"
value={
settings.libraryOptions.imageStyle === "poster"
}
@@ -124,12 +124,12 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='poster-title'>
<DropdownMenu.ItemTitle key="poster-title">
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='cover-option'
key="cover-option"
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
@@ -141,17 +141,17 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='cover-title'>
<DropdownMenu.ItemTitle key="cover-title">
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key='show-titles-group'>
<DropdownMenu.Group key="show-titles-group">
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key='show-titles-option'
key="show-titles-option"
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
@@ -159,30 +159,30 @@ export default function IndexLayout() {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on",
showTitles: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-titles-title'>
<DropdownMenu.ItemTitle key="show-titles-title">
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='show-stats-option'
key="show-stats-option"
value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on",
showStats: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-stats-title'>
<DropdownMenu.ItemTitle key="show-stats-title">
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
@@ -195,12 +195,12 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name='[libraryId]'
name="[libraryId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
@@ -208,12 +208,12 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>

View File

@@ -1,6 +1,6 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
@@ -11,9 +11,9 @@ import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function index() {
const [api] = useAtom(apiAtom);
@@ -23,7 +23,7 @@ export default function index() {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
@@ -41,7 +41,7 @@ export default function index() {
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
[data, settings?.hiddenLibraries]
);
useEffect(() => {
@@ -65,24 +65,22 @@ export default function index() {
if (isLoading)
return (
<View className='justify-center items-center h-full'>
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!libraries)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior='automatic'
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
@@ -99,10 +97,10 @@ export default function index() {
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-800 mx-2 my-4'
/>
className="bg-neutral-800 mx-2 my-4"
></View>
) : (
<View className='h-4' />
<View className="h-4" />
)
}
estimatedItemSize={200}

View File

@@ -3,15 +3,15 @@ import {
nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name='index'
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
@@ -20,7 +20,7 @@ export default function SearchLayout() {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
@@ -28,26 +28,26 @@ export default function SearchLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen
name='jellyseerr/person/[personId]'
name="jellyseerr/person/[personId]"
options={commonScreenOptions}
/>
<Stack.Screen
name='jellyseerr/company/[companyId]'
name="jellyseerr/company/[companyId]"
options={commonScreenOptions}
/>
<Stack.Screen
name='jellyseerr/genre/[genreId]'
name="jellyseerr/genre/[genreId]"
options={commonScreenOptions}
/>
</Stack>

View File

@@ -1,13 +1,10 @@
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 { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import {
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
@@ -15,28 +12,26 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import type {
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover";
@@ -53,8 +48,6 @@ export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { q } = params as { q: string };
@@ -65,27 +58,17 @@ export default function search() {
const [debouncedSearch] = useDebounce(search, 500);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
JellyseerrSearchSort.DEFAULT
] as unknown as JellyseerrSearchSort,
);
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
}, [settings]);
useEffect(() => {
if (q && q.length > 0) {
setSearch(q);
}
if (q && q.length > 0) setSearch(q);
}, [q]);
const searchFn = useCallback(
@@ -96,94 +79,62 @@ export default function search() {
types: BaseItemKind[];
query: string;
}): Promise<BaseItemDto[]> => {
if (!api || !query) {
return [];
}
if (!api || !query) return [];
try {
if (searchEngine === "Jellyfin") {
const searchApi = await getItemsApi(api).getItems({
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
});
return (searchApi.data.Items as BaseItemDto[]) || [];
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else {
if (!settings?.marlinServerUrl) return [];
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
}
if (!settings?.marlinServerUrl) {
return [];
}
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) {
return [];
}
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
} catch (error) {
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
}
},
[api, searchEngine, settings],
[api, searchEngine, settings]
);
type HeaderSearchBarRef = {
focus: () => void;
blur: () => void;
setText: (text: string) => void;
clearText: () => void;
cancelSearch: () => void;
};
const searchBarRef = useRef<HeaderSearchBarRef>(null);
const navigation = useNavigation();
useLayoutEffect(() => {
navigation.setOptions({
headerSearchBarOptions: {
ref: searchBarRef,
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: false,
autoFocus: true,
},
});
}, [navigation]);
useEffect(() => {
const unsubscribe = eventBus.on("searchTabPressed", () => {
// Screen not active
if (!searchBarRef.current) {
return;
}
// Screen is active, focus search bar
searchBarRef.current?.focus();
});
return () => {
unsubscribe();
};
}, []);
const { data: movies, isFetching: l1 } = useQuery({
queryKey: ["search", "movies", debouncedSearch],
queryFn: () =>
@@ -251,28 +202,25 @@ export default function search() {
return (
<>
<ScrollView
keyboardDismissMode='on-drag'
contentInsetAdjustmentBehavior='automatic'
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='flex flex-col'
className="flex flex-col"
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && (
<ScrollView
horizontal
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
>
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass='p-1'
textClass="p-1"
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
@@ -281,49 +229,16 @@ export default function search() {
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
textClass="p-1"
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
{searchType === "Discover" &&
!loading &&
noResults &&
debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id='search'
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
showSearch={false}
/>
<FilterButton
id='order'
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
showSearch={false}
/>
</View>
)}
</ScrollView>
</View>
)}
<View className='mt-2'>
<View className="mt-2">
<LoadingSkeleton isLoading={loading} />
</View>
@@ -331,50 +246,50 @@ export default function search() {
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.movies")}
items={movies}
ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className='flex flex-col w-28 mr-2'
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={series}
ids={series?.map((m) => m.Id!)}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={episodes}
ids={episodes?.map((m) => m.Id!)}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-44 mr-2'
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
@@ -382,29 +297,29 @@ export default function search() {
)}
/>
<SearchItemWrapper
items={collections}
ids={collections?.map((m) => m.Id!)}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={actors}
ids={actors?.map((m) => m.Id!)}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
@@ -413,39 +328,35 @@ export default function search() {
/>
</View>
) : (
<JellyserrIndexPage
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
<JellyserrIndexPage searchQuery={debouncedSearch} />
)}
{searchType === "Library" &&
(!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className='text-center text-lg font-bold mt-4'>
{t("search.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);
searchBarRef.current?.setText(e);
}}
key={e}
className='mb-2'
>
<Text className='text-purple-600'>{e}</Text>
</TouchableOpacity>
))}
</View>
) : null)}
{searchType === "Library" && (
<>
{!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
{t("search.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>
</>

View File

@@ -1,20 +1,20 @@
import React, { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
type NativeBottomTabNavigationEventMap,
createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
import type {
ParamListBase,
@@ -46,12 +46,12 @@ export default function TabLayout() {
clearTimeout(timer);
};
}
}, []),
}, [])
);
return (
<>
<SystemBars hidden={false} style='light' />
<SystemBars hidden={false} style="light" />
<NativeTabs
sidebarAdaptable={false}
ignoresTopSafeArea
@@ -59,20 +59,15 @@ export default function TabLayout() {
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance='default'
scrollEdgeAppearance="default"
>
<NativeTabs.Screen redirect name='index' />
<NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("scrollToTop");
},
})}
name='(home)'
name="(home)"
options={{
title: t("tabs.home"),
tabBarIcon:
Platform.OS === "android"
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: ({ focused }) =>
@@ -82,16 +77,11 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("searchTabPressed");
},
})}
name='(search)'
name="(search)"
options={{
title: t("tabs.search"),
tabBarIcon:
Platform.OS === "android"
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: ({ focused }) =>
@@ -101,11 +91,11 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name='(favorites)'
name="(favorites)"
options={{
title: t("tabs.favorites"),
tabBarIcon:
Platform.OS === "android"
Platform.OS == "android"
? ({ color, focused, size }) =>
focused
? require("@/assets/icons/heart.fill.png")
@@ -117,11 +107,11 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name='(libraries)'
name="(libraries)"
options={{
title: t("tabs.library"),
tabBarIcon:
Platform.OS === "android"
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: ({ focused }) =>
@@ -131,13 +121,13 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name='(custom-links)'
name="(custom-links)"
options={{
title: t("tabs.custom_links"),
// @ts-expect-error
tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS === "android"
Platform.OS == "android"
? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused

View File

@@ -1,39 +1,34 @@
import { Stack } from "expo-router";
import React, { useEffect } from "react";
import { SystemBars } from "react-native-edge-to-edge";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { Stack } from "expo-router";
import React, { useLayoutEffect } from "react";
import { Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
export default function Layout() {
const [settings] = useSettings();
useLayoutEffect(() => {
if (Platform.isTV) return;
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
useEffect(() => {
if (settings.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return () => {
if (Platform.isTV) return;
if (settings.followDeviceOrientation === true) {
if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
};
});
}, [settings]);
return (
<>
<SystemBars hidden />
<Stack>
<Stack.Screen
name='direct-player'
name="direct-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,

View File

@@ -1,55 +1,51 @@
import { BITRATES } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import type {
import { VlcPlayerView } from "@/modules/vlc-player";
import {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
} from "@/modules/vlc-player/src/VlcPlayer.types";
// import { useDownload } from "@/providers/DownloadProvider";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import generateDeviceProfile from "@/utils/profiles/native";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
type PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -60,7 +56,6 @@ export default function page() {
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
@@ -68,10 +63,6 @@ export default function page() {
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
let getDownloadedItem = null;
if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload();
@@ -102,140 +93,152 @@ export default function page() {
offline: string;
}>();
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10)
: undefined;
const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10)
: -1;
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr
? Number.parseInt(bitrateValueStr, 10)
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null);
const [itemStatus, setItemStatus] = useState({
isLoading: true,
isError: false,
});
useEffect(() => {
const fetchItemData = async () => {
setItemStatus({ isLoading: true, isError: false });
try {
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto;
} else {
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
fetchedItem = res.data;
}
setItem(fetchedItem);
setItemStatus({ isLoading: false, isError: false });
} catch (error) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline && !Platform.isTV) {
const item = await getDownloadedItem.getDownloadedItem(itemId);
if (item) return item.item;
}
};
if (itemId) {
fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
return res.data;
},
enabled: !!itemId,
staleTime: 0,
});
const [stream, setStream] = useState<{
mediaSource: MediaSourceInfo;
url: string;
sessionId: string | undefined;
} | null>(null);
const [isLoadingStream, setIsLoadingStream] = useState(true);
const [isErrorStream, setIsErrorStream] = useState(false);
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
const fetchStream = async () => {
setIsLoadingStream(true);
setIsErrorStream(false);
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
if (!data?.mediaSource) {
setStream(null);
return;
}
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
setStream({
mediaSource: data.mediaSource as MediaSourceInfo,
url,
sessionId: undefined,
});
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) {
setStream(null);
return;
}
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
setStream(null);
return;
}
setStream({
mediaSource,
sessionId,
url,
});
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
console.error("Error fetching stream:", error);
setIsErrorStream(true);
setStream(null);
} finally {
setIsLoadingStream(false);
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
useEffect(() => {
if (!stream) return;
fetchStream();
}, [itemId, mediaSourceId]);
const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
};
const togglePlay = useCallback(async () => {
if (!api) return;
reportPlaybackStart();
}, [stream]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
reportPlaybackProgress();
} else {
videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
}
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
}
};
}, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress,
]);
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
@@ -244,15 +247,7 @@ export default function page() {
});
revalidateProgressCache();
}, [
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
}, [api, item, mediaSourceId, stream]);
const stop = useCallback(() => {
reportPlaybackStopped();
@@ -260,36 +255,12 @@ export default function page() {
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
if (!stream) return;
return {
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
isMuted: isMuted,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
@@ -298,184 +269,87 @@ export default function page() {
if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return;
reportPlaybackProgress();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
},
[
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
);
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item, offline]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.min(currentVolume + 0.1, 1.0);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []);
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const currentVolumePercent = currentVolume * 100;
if (currentVolumePercent > 0) {
// Currently not muted, so mute
setPreviousVolume(currentVolumePercent);
await VolumeManager.setVolume(0);
setIsMuted(true);
} else {
// Currently muted, so restore previous volume
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
await VolumeManager.setVolume(volumeToRestore / 100);
setPreviousVolume(null);
setIsMuted(false);
}
} catch (error) {
console.error("Error toggling mute:", error);
}
}, [previousVolume]);
const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
console.log(
"Volume Down",
Math.round(currentVolume * 100),
"→",
Math.round(newVolume * 100),
);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []);
const setVolumeCb = useCallback(async (newVolume: number) => {
if (Platform.isTV) return;
try {
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
console.log("Setting volume to", clampedVolume);
await VolumeManager.setVolume(clampedVolume / 100);
} catch (error) {
console.error("Error setting volume:", error);
}
}, []);
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline,
toggleMute: toggleMuteCb,
volumeUp: volumeUpCb,
volumeDown: volumeDownCb,
setVolume: setVolumeCb,
});
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
if (state === "Paused") {
setIsPlaying(false);
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
},
[reportPlaybackProgress],
);
if (state === "Playing") {
setIsPlaying(true);
if (!Platform.isTV) await activateKeepAwakeAsync()
return;
}
if (state === "Paused") {
setIsPlaying(false);
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
}, []);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio",
(audio) => audio.Type === "Audio"
) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
(sub) => sub.Type === "Subtitle"
) || [];
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
(sub) => sub.Index === subtitleIndex
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
@@ -490,6 +364,13 @@ export default function page() {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
@@ -498,18 +379,25 @@ export default function page() {
return () => setIsMounted(false);
}, []);
if (itemStatus.isLoading || streamStatus.isLoading) {
const insets = useSafeAreaInsets();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || !stream)
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
}
if (itemStatus.isError || streamStatus.isError)
if (isErrorItem || isErrorStream)
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
@@ -549,7 +437,7 @@ export default function page() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
t("player.an_error_occured_while_playing_the_video")
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
@@ -582,6 +470,7 @@ export default function page() {
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc
/>
) : null}

View File

@@ -1,5 +1,5 @@
import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react";
import { type PropsWithChildren } from "react";
/**
* This file is web-only and used to configure the root HTML for every web page during static rendering.
@@ -7,13 +7,13 @@ import type { PropsWithChildren } from "react";
*/
export default function Root({ children }: PropsWithChildren) {
return (
<html lang='en'>
<html lang="en">
<head>
<meta charSet='utf-8' />
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta
name='viewport'
content='width=device-width, initial-scale=1, shrink-to-fit=no'
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
{/*

View File

@@ -9,9 +9,9 @@ export default function NotFoundScreen() {
<>
<Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}>
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href={"/home"} style={styles.link}>
<ThemedText type='link'>Go to home screen!</ThemedText>
<ThemedText type="link">Go to home screen!</ThemedText>
</Link>
</ThemedView>
</>

View File

@@ -1,33 +1,30 @@
import "@/augmentations";
import { Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
JellyfinProvider,
apiAtom,
getOrSetDeviceId,
getServerUrlFromStorage,
getTokenFromStorage,
getUserFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings";
import { Settings, useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_SESSIONS,
registerBackgroundFetchAsyncSessions,
BACKGROUND_FETCH_TASK_RECENTLY_ADDED,
registerBackgroundFetchAsyncRecentlyAdded,
unregisterBackgroundFetchAsyncRecentlyAdded,
} from "@/utils/background-tasks";
import {
LogProvider,
writeDebugLog,
writeErrorLog,
writeToLog,
} from "@/utils/log";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
@@ -36,32 +33,23 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { Stack, router, useSegments } from "expo-router";
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { I18nextProvider } from "react-i18next";
import { AppState, Appearance } from "react-native";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import type { EventSubscription } from "expo-modules-core";
import type {
Notification,
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
import { Jellyfin } from "@jellyfin/sdk";
import { fetchAndStoreRecentlyAdded } from "@/utils/recently-added-notifications";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -101,13 +89,13 @@ function useNotificationObserver() {
return;
}
redirect(response?.notification);
},
}
);
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
}
);
return () => {
@@ -118,18 +106,26 @@ function useNotificationObserver() {
}
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
console.log("TaskManager ~ sessions trigger");
TaskManager.defineTask(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, async () => {
const token = getTokenFromStorage();
const url = getServerUrlFromStorage();
const user = getUserFromStorage();
const api = store.get(apiAtom);
if (api === null || api === undefined) return;
const c = storage.getNumber("notification_send_for_item_ids.count");
storage.set("notification_send_for_item_ids.count", (c || 0) + 1);
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: 360,
});
console.log(
"TaskManager ~ trigger ~ recently added notifications:",
token,
url,
user?.Id
);
const result = response.data.filter((s) => s.NowPlayingItem);
Notifications.setBadgeCountAsync(result.length);
if (!token || !url || !user?.Id) return;
const result = await fetchAndStoreRecentlyAdded(user.Id, url, token);
if (!result) return BackgroundFetch.BackgroundFetchResult.NoData;
return BackgroundFetch.BackgroundFetchResult.NewData;
});
@@ -164,9 +160,9 @@ if (!Platform.isTV) {
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const job of jobs) {
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = `${url}download/${job.id}`;
const downloadUrl = url + "download/" + job.id;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
@@ -199,7 +195,7 @@ if (!Platform.isTV) {
title: job.item.Name,
body: "Download completed",
data: {
url: "/downloads",
url: `/downloads`,
},
},
trigger: null,
@@ -213,7 +209,7 @@ if (!Platform.isTV) {
title: job.item.Name,
body: "Download failed",
data: {
url: "/downloads",
url: `/downloads`,
},
},
trigger: null,
@@ -232,7 +228,7 @@ if (!Platform.isTV) {
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission",
"hasAskedForNotificationPermission"
);
if (hasAskedBefore !== "true") {
@@ -254,7 +250,7 @@ const checkAndRequestPermissions = async () => {
writeToLog(
"ERROR",
"Error checking/requesting notification permissions:",
error,
error
);
console.error("Error checking/requesting notification permissions:", error);
}
@@ -290,145 +286,58 @@ const queryClient = new QueryClient({
function Layout() {
const [settings] = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
const segments = useSegments();
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
);
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
const notificationListener = useRef<EventSubscription>();
const responseListener = useRef<EventSubscription>();
useEffect(() => {
if (expoPushToken && api && user) {
api
?.post("/Streamyfin/device", {
token: expoPushToken.data,
deviceId: getOrSetDeviceId(),
userId: user.Id,
})
.then((_) => console.log("Posted expo push token"))
.catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"),
);
} else console.log("No token available");
}, [api, expoPushToken, user]);
async function registerNotifications() {
if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
}
await checkAndRequestPermissions();
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
}
useEffect(() => {
registerNotifications();
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
console.log(
"Notification received while app running",
notification,
);
},
);
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
// Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content;
writeDebugLog(
`Notification ${title} opened`,
response.notification.request.content,
);
if (data && Object.keys(data).length > 0) {
const type = data?.type?.toLower?.();
const itemId = data?.id;
switch (type) {
case "movie":
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
case "episode":
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
}
// summarized season notification for multiple episodes. Bring them to series season
else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
}
break;
}
}
},
);
return () => {
notificationListener.current &&
Notifications?.removeNotificationSubscription(
notificationListener.current,
);
responseListener.current &&
Notifications?.removeNotificationSubscription(
responseListener.current,
);
};
checkAndRequestPermissions();
}, []);
useEffect(() => {
const checkStatusAsync = async () => {
if (Platform.isTV) return;
if (segments.includes("direct-player" as never)) {
return;
}
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(
BACKGROUND_FETCH_TASK_RECENTLY_ADDED
);
};
useEffect(() => {
(async () => {
const isRegistered = await checkStatusAsync();
if (settings.recentlyAddedNotifications === false && isRegistered) {
console.log("unregisterBackgroundFetchAsyncRecentlyAdded");
unregisterBackgroundFetchAsyncRecentlyAdded();
} else if (
settings.recentlyAddedNotifications === true &&
!isRegistered
) {
console.log("registerBackgroundFetchAsyncRecentlyAdded");
registerBackgroundFetchAsyncRecentlyAdded();
}
})();
}, [settings.recentlyAddedNotifications]);
useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation
if (settings.followDeviceOrientation === true) {
if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
}, [settings.followDeviceOrientation, segments]);
}, [settings]);
useEffect(() => {
const subscription = AppState.addEventListener(
@@ -440,7 +349,7 @@ function Layout() {
) {
BackGroundDownloader.checkForExistingDownloads();
}
},
}
);
BackGroundDownloader.checkForExistingDownloads();
@@ -460,11 +369,11 @@ function Layout() {
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style='light' hidden={false} />
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName="(auth)/(tabs)">
<Stack.Screen
name='(auth)/(tabs)'
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
@@ -472,7 +381,7 @@ function Layout() {
}}
/>
<Stack.Screen
name='(auth)/player'
name="(auth)/player"
options={{
headerShown: false,
title: "",
@@ -480,14 +389,14 @@ function Layout() {
}}
/>
<Stack.Screen
name='login'
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
@@ -518,7 +427,7 @@ function Layout() {
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
const items: BaseItemDto[] = downloadedItems
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];

View File

@@ -1,17 +1,16 @@
import { Button } from "@/components/Button";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useAtom, useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@@ -22,8 +21,8 @@ import {
} from "react-native";
import { Keyboard } from "react-native";
import { t } from "i18next";
import { z } from "zod";
import { t } from "i18next";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
@@ -59,7 +58,7 @@ const Login: React.FC = () => {
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({
setServer({
address: _apiUrl,
});
@@ -82,10 +81,10 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
className='flex flex-row items-center'
className="flex flex-row items-center"
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className='ml-2 text-purple-600'>
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">
{t("login.change_server")}
</Text>
</TouchableOpacity>
@@ -108,7 +107,7 @@ const Login: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
t("login.an_unexpected_error_occured")
);
}
} finally {
@@ -177,12 +176,12 @@ const Login: React.FC = () => {
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
t("login.could_not_connect_to_server")
);
return;
}
await setServer({ address: url });
setServer({ address: url });
}, []);
const handleQuickConnect = async () => {
@@ -196,13 +195,13 @@ const Login: React.FC = () => {
{
text: t("login.got_it"),
},
],
]
);
}
} catch (error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
t("login.failed_to_initiate_quick_connect")
);
}
};
@@ -214,20 +213,22 @@ const Login: React.FC = () => {
>
{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'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</>
</Text>
<Text className='text-xs text-neutral-400'>
<Text className="text-xs text-neutral-400">
{api.basePath}
</Text>
<Input
@@ -236,13 +237,12 @@ const Login: React.FC = () => {
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType='oneTimeCode'
clearButtonMode='while-editing'
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
@@ -253,42 +253,42 @@ const Login: React.FC = () => {
}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
<View className='flex flex-row items-center justify-between'>
<View className="flex flex-row items-center justify-between">
<Button
onPress={handleLogin}
loading={loading}
className='flex-1 mr-2'
className="flex-1 mr-2"
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
>
<MaterialCommunityIcons
name='cellphone-lock'
name="cellphone-lock"
size={24}
color='white'
color="white"
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
<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'>
<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,
@@ -298,19 +298,19 @@ const Login: React.FC = () => {
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
aria-label="Server URL"
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button
@@ -319,22 +319,22 @@ const Login: React.FC = () => {
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
className="w-full grow"
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
onServerSelect={(server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
onServerSelect={(s) => {
handleConnect(s.address);
}}
/>
</View>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,21 +1,17 @@
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
import type { AxiosRequestConfig, AxiosResponse } from "axios";
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>,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
post<T, D = any>(
url: string,
data: D,
config?: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>>;
delete<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
}
@@ -23,7 +19,7 @@ declare module "@jellyfin/sdk" {
Api.prototype.get = function <T, D = any>(
url: string,
config: AxiosRequestConfig<D> = {},
config: AxiosRequestConfig<D> = {}
): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
...(config ?? {}),
@@ -34,20 +30,11 @@ Api.prototype.get = function <T, D = any>(
Api.prototype.post = function <T, D = any>(
url: string,
data: D,
config: AxiosRequestConfig<D>,
config: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
...(config || {}),
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};
Api.prototype.delete = function <T, D = any>(
url: string,
config: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
...(config || {}),
data,
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
module.exports = (api) => {
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],

View File

@@ -1,61 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignore": [
"node_modules",
"ios",
"android",
"Streamyfin.app",
"utils/jellyseerr",
".expo"
]
},
"linter": {
"enabled": true,
"rules": {
"style": {
"useImportType": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useLiteralEnumMembers": "off"
},
"complexity": {
"noForEach": "off"
},
"recommended": true,
"correctness": { "useExhaustiveDependencies": "off" },
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": true,
"attributePosition": "auto",
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80
},
"javascript": {
"formatter": {
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"jsxQuoteStyle": "single",
"quoteProperties": "asNeeded",
"semicolons": "always",
"lineWidth": 80
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

385
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,113 @@
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
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: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
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'
size="large"
icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite}
onPress={() => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/>
</View>
);

View File

@@ -1,9 +1,9 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -20,31 +20,31 @@ export const AudioTrackSelector: React.FC<Props> = ({
if (Platform.isTV) return null;
const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source],
[source]
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected],
[audioStreams, selected]
);
const { t } = useTranslation();
return (
<View
className='flex shrink'
className="flex shrink"
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
{t("item_card.audio")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className='' numberOfLines={1}>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
</Text>
</TouchableOpacity>
@@ -52,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,4 +1,4 @@
import { View, type ViewProps } from "react-native";
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
${variant === "gray" && "bg-neutral-800"}
`}
>
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
{iconLeft && <View className="mr-1">{iconLeft}</View>}
<Text
className={`
text-xs

View File

@@ -1,8 +1,8 @@
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
export type Bitrate = {
key: string;
@@ -40,11 +40,7 @@ export const BITRATES: Bitrate[] = [
key: "250 Kb/s",
value: 250000,
},
].sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
@@ -62,14 +58,10 @@ export const BitrateSelector: React.FC<Props> = ({
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
(a, b) =>
(a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY),
(a, b) => (a.value || Infinity) - (b.value || Infinity)
);
return BITRATES.sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
(a, b) => (b.value || Infinity) - (a.value || Infinity)
);
}, []);
@@ -77,7 +69,7 @@ export const BitrateSelector: React.FC<Props> = ({
return (
<View
className='flex shrink'
className="flex shrink"
style={{
minWidth: 60,
maxWidth: 200,
@@ -85,12 +77,12 @@ export const BitrateSelector: React.FC<Props> = ({
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
{t("item_card.quality")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
<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}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
@@ -98,8 +90,8 @@ export const BitrateSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}

View File

@@ -1,6 +1,5 @@
import { useHaptic } from "@/hooks/useHaptic";
import type React from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -64,7 +63,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{...props}
>
{loading ? (
<View className='p-0.5'>
<View className="p-0.5">
<Loader />
</View>
) : (
@@ -73,7 +72,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`}
>
{iconLeft ? iconLeft : <View className='w-4' />}
{iconLeft ? iconLeft : <View className="w-4"></View>}
<Text
className={`
text-white font-bold text-base
@@ -85,7 +84,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
>
{children}
</Text>
{iconRight ? iconRight : <View className='w-4' />}
{iconRight ? iconRight : <View className="w-4"></View>}
</View>
)}
</TouchableOpacity>

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -45,18 +45,18 @@ export function Chromecast({
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor='transparent' />
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS],
[Platform.OS]
);
if (background === "transparent")
return (
<RoundButton
size='large'
className='mr-2'
size="large"
className="mr-2"
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -65,13 +65,13 @@ export function Chromecast({
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);
return (
<RoundButton
size='large'
size="large"
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
@@ -79,7 +79,7 @@ export function Chromecast({
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);
}

View File

@@ -1,12 +1,12 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import type React from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
@@ -27,39 +27,33 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
* Get horizontal poster for movie and episode, with failover to primary.
*/
const url = useMemo(() => {
if (!api) {
return;
}
if (!api) return;
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
if (item.ParentBackdropItemId && item.ParentThumbImageTag)
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
}
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]);
const progress = useMemo(() => {
@@ -70,12 +64,15 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
} else {
return item.UserData?.PlayedPercentage || 0;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
if (!url)
return <View className='aspect-video border border-neutral-800 w-44' />;
return (
<View className="aspect-video border border-neutral-800 w-44"></View>
);
return (
<View
@@ -84,7 +81,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"}
`}
>
<View className='w-full h-full flex items-center justify-center'>
<View className="w-full h-full flex items-center justify-center">
<Image
key={item.Id}
id={item.Id}
@@ -92,12 +89,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
className='w-full h-full'
contentFit="cover"
className="w-full h-full"
/>
{showPlayButton && (
<View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name='play-circle' size={40} color='white' />
<View className="absolute inset-0 flex items-center justify-center">
<Ionicons name="play-circle" size={40} color="white" />
</View>
)}
</View>
@@ -105,16 +102,14 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
{progress > 0 && (
<>
<View
className={
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
></View>
<View
style={{
width: `${progress}%`,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
></View>
</>
)}
</View>

View File

@@ -1,7 +1,8 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -9,30 +10,29 @@ import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type {
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router, useFocusEffect } from "expo-router";
import { t } from "i18next";
import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { Text } from "./common/Text";
import { t } from "i18next";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
@@ -58,7 +58,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [settings] = useSettings();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
//const { startRemuxing } = useRemuxHlsToMp4();
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
@@ -66,20 +66,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
},
);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
});
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user],
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
[settings]
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -99,7 +97,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsNotDownloaded = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles],
[items, downloadedFiles]
);
const allItemsDownloaded = useMemo(() => {
@@ -108,11 +106,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds],
[processes, itemIds]
);
const progress = useMemo(() => {
if (itemIds.length === 1)
if (itemIds.length == 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return (
((itemIds.length -
@@ -125,7 +123,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsQueued = useMemo(() => {
return (
itemsNotDownloaded.length > 0 &&
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
);
}, [queue, itemsNotDownloaded]);
const navigateToDownloads = () => router.push("/downloads");
@@ -140,7 +138,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href),
} as Href)
);
};
@@ -151,11 +149,20 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}
closeModal();
initiateDownload(...itemsNotDownloaded);
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
);
}
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
}
}, [
queue,
@@ -178,7 +185,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item",
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
let mediaSource = selectedMediaSource;
@@ -191,6 +198,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({
@@ -203,14 +211,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
});
if (!res) {
Alert.alert(
t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
t("home.downloads.could_not_get_stream_url_from_jellyfin")
);
continue;
}
@@ -220,7 +226,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
await startBackgroundDownload(url, item, source, maxBitrate);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
}
}
},
[
@@ -234,7 +245,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
],
startRemuxing,
]
);
const renderBackdrop = useCallback(
@@ -245,7 +257,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
appearsOnIndex={0}
/>
),
[],
[]
);
useFocusEffect(
useCallback(() => {
@@ -258,35 +270,31 @@ export const DownloadItems: React.FC<DownloadProps> = ({
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings]),
}, [items, itemsNotDownloaded, settings])
);
const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) {
if (processes && itemsProcesses.length > 0) {
return progress === 0 ? (
<Loader />
) : (
<View className='-rotate-45'>
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor='#9334E9'
backgroundColor='#bdc3c7'
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
);
}
if (itemsQueued) {
return <Ionicons name='hourglass' size={24} color='white' />;
}
if (allItemsDownloaded) {
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
}
return <MissingDownloadIconComponent />;
};
const onButtonPress = () => {
@@ -319,19 +327,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className='font-bold text-2xl text-neutral-100'>
<Text className="font-bold text-2xl text-neutral-100">
{title}
</Text>
<Text className='text-neutral-300'>
{subtitle ||
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
})}
<Text className="text-neutral-300">
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
</Text>
</View>
<View className='flex flex-col space-y-2 w-full items-start'>
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={setMaxBitrate}
@@ -345,7 +350,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className='flex flex-col space-y-2'>
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
@@ -362,14 +367,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
</View>
<Button
className='mt-auto'
className="mt-auto"
onPress={acceptDownloadOptions}
color='purple'
color="purple"
>
{t("item_card.download.download_button")}
</Button>
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
@@ -386,23 +391,19 @@ export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
if (Platform.isTV) return;
return (
<DownloadItems
size={size}
title={
item.Type === "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")
}
title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name='cloud-download-outline' size={24} color='white' />
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name='cloud-download' size={26} color='#9333ea' />
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
);

View File

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

View File

@@ -1,8 +1,8 @@
import { tc } from "@/utils/textTools";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
type ItemCardProps = {
item: BaseItemDto;
@@ -10,13 +10,13 @@ type ItemCardProps = {
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className='mt-2 flex flex-col'>
<View className="mt-2 flex flex-col">
{item.Type === "Episode" ? (
<>
<Text numberOfLines={1} ellipsizeMode='tail' className=''>
<Text numberOfLines={1} className="">
{item.Name}
</Text>
<Text numberOfLines={1} className='text-xs opacity-50'>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
@@ -24,10 +24,8 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
</>
) : (
<>
<Text numberOfLines={1} ellipsizeMode='tail'>
{item.Name}
</Text>
<Text className='text-xs opacity-50'>{item.ProductionYear}</Text>
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
</>
)}
</View>

View File

@@ -1,5 +1,5 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -15,28 +15,26 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import type {
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AddToFavorites } from "./AddToFavorites";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { AddToFavorites } from "./AddToFavorites";
export type SelectedOptions = {
bitrate: Bitrate;
@@ -52,8 +50,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
@@ -90,23 +86,17 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
navigation.setOptions({
headerRight: () =>
item && (
<View className='flex flex-row items-center space-x-2'>
<View className="flex flex-row items-center space-x-2">
<Chromecast.Chromecast
background='blur'
background="blur"
width={22}
height={22}
/>
{item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" />
<PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} type="item" />
</View>
)}
</View>
@@ -131,7 +121,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return (
<View
className='flex-1 relative'
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
@@ -155,38 +145,41 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
}
logo={
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-4' />
<View className="flex flex-col bg-transparent shrink">
{/* {!Platform.isTV && ( */}
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && !Platform.isTV && (
<View className='flex flex-row items-center justify-start w-full h-16'>
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className='mr-1'
className="mr-1"
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
(prev) => prev && { ...prev, bitrate: val }
)
}
selected={selectedOptions.bitrate}
/>
<MediaSourceSelector
className='mr-1'
className="mr-1"
item={item}
onChange={(val) =>
setSelectedOptions(
@@ -194,13 +187,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
mediaSource: val,
},
}
)
}
selected={selectedOptions.mediaSource}
/>
<AudioTrackSelector
className='mr-1'
className="mr-1"
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
@@ -208,7 +201,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
audioIndex: val,
},
}
);
}}
selected={selectedOptions.audioIndex}
@@ -221,7 +214,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
subtitleIndex: val,
},
}
)
}
selected={selectedOptions.subtitleIndex}
@@ -229,11 +222,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{/* {!Platform.isTV && ( */}
<PlayButton
className='grow'
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
{/* )} */}
</View>
{item.Type === "Episode" && (
@@ -241,24 +236,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)}
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className='px-4 mb-4' />
<OverviewText text={item.Overview} className="px-4 mb-4" />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
<CurrentSeries item={item} className='mb-4' />
<CurrentSeries item={item} className="mb-4" />
)}
<CastAndCrew item={item} className='mb-4' loading={loading} />
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && (
<View className='mb-4'>
<View className="mb-4">
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className='mb-4'
className="mb-4"
/>
))}
</View>
@@ -271,5 +266,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</ParallaxScrollView>
</View>
);
},
}
);

View File

@@ -1,9 +1,9 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View, ViewProps } from "react-native";
import { GenreTags } from "./GenreTags";
import { Ratings } from "./Ratings";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { ItemActions } from "./series/SeriesActions";
@@ -15,21 +15,21 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className='flex flex-col space-y-1.5 w-full items-start h-32'
className="flex flex-col space-y-1.5 w-full items-start h-32"
{...props}
>
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
<View className='w-2/3 h-8 bg-neutral-900 rounded' />
<View className='w-2/3 h-4 bg-neutral-900 rounded' />
<View className='w-1/4 h-4 bg-neutral-900 rounded' />
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
</View>
);
return (
<View className='flex flex-col' {...props}>
<View className='flex flex-col' {...props}>
<View className='flex flex-row items-center justify-between'>
<Ratings item={item} className='mb-2' />
<View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}>
<View className="flex flex-row items-center justify-between">
<Ratings item={item} className="mb-2" />
<ItemActions item={item} />
</View>
{item.Type === "Episode" && (

View File

@@ -1,23 +1,21 @@
import { formatBitrate } from "@/utils/bitrate";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo,
MediaStream,
type MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import type React from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import React, { useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "./Badge";
import { Button } from "./Button";
import { Text } from "./common/Text";
import {
BottomSheetModal,
BottomSheetBackdropProps,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
import { useTranslation } from "react-i18next";
interface Props {
source?: MediaSourceInfo;
@@ -28,13 +26,13 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const { t } = useTranslation();
return (
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className='flex flex-row space-x-2'>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -54,37 +52,31 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
)}
>
<BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4 mb-4'>
<View className=''>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className='flex flex-row space-x-2'>
<View className="flex flex-col space-y-2 p-4 mb-4">
<View className="">
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
</View>
<View className=''>
<Text className='text-lg font-bold mb-2'>
{t("item_card.audio")}
</Text>
<View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio",
(stream) => stream.Type === "Audio"
) || []
}
/>
</View>
<View className=''>
<Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")}
</Text>
<View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle",
(stream) => stream.Type === "Subtitle"
) || []
}
/>
@@ -102,25 +94,25 @@ const SubtitleStreamInfo = ({
subtitleStreams: MediaStream[];
}) => {
return (
<View className='flex flex-col'>
<View className="flex flex-col">
{subtitleStreams.map((stream, index) => (
<View key={stream.Index} className='flex flex-col'>
<Text className='text-xs mb-3 text-neutral-400'>
<View key={stream.Index} className="flex flex-col">
<Text className="text-xs mb-3 text-neutral-400">
{stream.DisplayTitle}
</Text>
<View className='flex flex-row flex-wrap gap-2'>
<View className="flex flex-row flex-wrap gap-2">
<Badge
variant='gray'
variant="gray"
iconLeft={
<Ionicons name='language-outline' size={16} color='white' />
<Ionicons name="language-outline" size={16} color="white" />
}
text={stream.Language}
/>
<Badge
variant='gray'
variant="gray"
text={stream.Codec}
iconLeft={
<Ionicons name='layers-outline' size={16} color='white' />
<Ionicons name="layers-outline" size={16} color="white" />
}
/>
</View>
@@ -132,40 +124,40 @@ const SubtitleStreamInfo = ({
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return (
<View className='flex flex-col'>
<View className="flex flex-col">
{audioStreams.map((audioStreams, index) => (
<View key={index} className='flex flex-col'>
<Text className='mb-3 text-neutral-400 text-xs'>
<View key={index} className="flex flex-col">
<Text className="mb-3 text-neutral-400 text-xs">
{audioStreams.DisplayTitle}
</Text>
<View className='flex-row flex-wrap gap-2'>
<View className="flex-row flex-wrap gap-2">
<Badge
variant='gray'
variant="gray"
iconLeft={
<Ionicons name='language-outline' size={16} color='white' />
<Ionicons name="language-outline" size={16} color="white" />
}
text={audioStreams.Language}
/>
<Badge
variant='gray'
variant="gray"
iconLeft={
<Ionicons
name='musical-notes-outline'
name="musical-notes-outline"
size={16}
color='white'
color="white"
/>
}
text={audioStreams.Codec}
/>
<Badge
variant='gray'
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
variant="gray"
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
text={audioStreams.ChannelLayout}
/>
<Badge
variant='gray'
variant="gray"
iconLeft={
<Ionicons name='speedometer-outline' size={16} color='white' />
<Ionicons name="speedometer-outline" size={16} color="white" />
}
text={formatBitrate(audioStreams.BitRate)}
/>
@@ -181,48 +173,48 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
const videoStream = useMemo(() => {
return source.MediaStreams?.find(
(stream) => stream.Type === "Video",
(stream) => stream.Type === "Video"
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return (
<View className='flex-row flex-wrap gap-2'>
<View className="flex-row flex-wrap gap-2">
<Badge
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={formatFileSize(source.Size)}
/>
<Badge
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
<Badge
variant='gray'
variant="gray"
iconLeft={
<Ionicons name='color-palette-outline' size={16} color='white' />
<Ionicons name="color-palette-outline" size={16} color="white" />
}
text={videoStream.VideoRange}
/>
<Badge
variant='gray'
variant="gray"
iconLeft={
<Ionicons name='code-working-outline' size={16} color='white' />
<Ionicons name="code-working-outline" size={16} color="white" />
}
text={videoStream.Codec}
/>
<Badge
variant='gray'
variant="gray"
iconLeft={
<Ionicons name='speedometer-outline' size={16} color='white' />
<Ionicons name="speedometer-outline" size={16} color="white" />
}
text={formatBitrate(videoStream.BitRate)}
/>
<Badge
variant='gray'
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
variant="gray"
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
/>
</View>
@@ -234,8 +226,15 @@ const formatFileSize = (bytes?: number | null) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
);
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
};
const formatBitrate = (bitrate?: number | null) => {
if (!bitrate) return "N/A";
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
if (bitrate === 0) return "0 bps";
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
};

View File

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

View File

@@ -1,6 +1,6 @@
import {
ActivityIndicator,
type ActivityIndicatorProps,
ActivityIndicatorProps,
Platform,
View,
} from "react-native";

View File

@@ -1,12 +1,12 @@
import type {
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -24,9 +24,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video",
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item, selected],
[item, selected]
);
const { t } = useTranslation();
@@ -54,26 +54,26 @@ export const MediaSourceSelector: React.FC<Props> = ({
return (
<View
className='flex shrink'
className="flex shrink"
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
{t("item_card.video")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,17 +1,17 @@
import { ItemCardText } from "@/components/ItemCardText";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import React from "react";
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { ItemCardText } from "@/components/ItemCardText";
import { useAtom } from "jotai";
import type React from "react";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
actorId: string;
@@ -63,8 +63,9 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
const x = acc.find((item) => item.Id === current.Id);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
return acc;
}, [] as BaseItemDto[]) || [];
return uniqueItems;
@@ -76,8 +77,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return (
<View {...props}>
<Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.more_with", { name: actor?.Name })}
<Text className="text-lg font-bold mb-2 px-4">
{t("item_card.more_with", {name: actor?.Name})}
</Text>
<HorizontalScroll
data={items}
@@ -87,7 +88,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
<TouchableItemRouter
key={idx}
item={item}
className='flex flex-col w-28'
className="flex flex-col w-28"
>
<View>
<MoviePoster item={item} />

View File

@@ -1,8 +1,8 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
interface Props extends ViewProps {
text?: string | null;
@@ -20,22 +20,20 @@ export const OverviewText: React.FC<Props> = ({
if (!text) return null;
return (
<View className='flex flex-col' {...props}>
<Text className='text-lg font-bold mb-2'>{t("item_card.overview")}</Text>
<View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit,
prev === characterLimit ? text.length : characterLimit
)
}
>
<View>
<Text>{tc(text, limit)}</Text>
{text.length > characterLimit && (
<Text className='text-purple-600 mt-1'>
{limit === characterLimit
? t("item_card.show_more")
: t("item_card.show_less")}
<Text className="text-purple-600 mt-1">
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
</Text>
)}
</View>

View File

@@ -1,11 +1,6 @@
import { LinearGradient } from "expo-linear-gradient";
import type { PropsWithChildren, ReactElement } from "react";
import {
type NativeScrollEvent,
NativeSyntheticEvent,
View,
type ViewProps,
} from "react-native";
import { type PropsWithChildren, type ReactElement } from "react";
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -40,40 +35,36 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
translateY: interpolate(
scrollOffset.value,
[-headerHeight, 0, headerHeight],
[-headerHeight / 2, 0, headerHeight * 0.75],
[-headerHeight / 2, 0, headerHeight * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
[-headerHeight, 0, headerHeight],
[2, 1, 1],
[2, 1, 1]
),
},
],
};
});
function isCloseToBottom({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) {
return (
layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
);
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
}
return (
<View className='flex-1' {...props}>
<View className="flex-1" {...props}>
<Animated.ScrollView
style={{
position: "relative",
}}
ref={scrollRef}
scrollEventThrottle={16}
onScroll={(e) => {
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
onScroll={e => {
if (isCloseToBottom(e.nativeEvent))
onEndReached?.()
}}
>
{logo && (
@@ -82,7 +73,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
top: headerHeight - 200,
height: 130,
}}
className='absolute left-0 w-full z-40 px-4 flex justify-center items-center'
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
>
{logo}
</View>
@@ -104,7 +95,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{
top: -50,
}}
className='relative flex-1 bg-transparent pb-24'
className="relative flex-1 bg-transparent pb-24"
>
<LinearGradient
// Background Linear Gradient

View File

@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur";
import type React from "react";
import { Platform, View, type ViewProps } from "react-native";
import React from "react";
import { Platform, View, ViewProps } from "react-native";
interface Props extends ViewProps {
blurAmount?: number;
blurType?: "light" | "dark" | "xlight";

View File

@@ -1,21 +1,18 @@
import { useHaptic } from "@/hooks/useHaptic";
import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Pressable } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
@@ -33,8 +30,13 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
const chromecastProfile = !Platform.isTV
? require("@/utils/profiles/chromecast")
: null;
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -66,21 +68,17 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings, updateSettings] = useSettings();
const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string) => {
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
}
(q: string, bitrateValue: number | undefined) => {
router.push(`/player/direct-player?${q}`);
},
[router],
[router]
);
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
@@ -96,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString();
if (!client) {
goToPlayer(queryString);
goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
}
@@ -115,63 +113,59 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
// Check if user wants H265 for Chromecast
const enableH265 = settings.enableH265ForChromecast;
if (!Platform.isTV) {
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
// Get a new URL with the Chromecast device profile:
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
@@ -202,29 +196,30 @@ export const PlayButton: React.FC<Props> = ({
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
}
}
});
});
}
break;
case 1:
goToPlayer(queryString);
goToPlayer(queryString, selectedOptions.bitrate?.value);
break;
case cancelButtonIndex:
break;
}
},
}
);
}, [
item,
@@ -241,11 +236,11 @@ export const PlayButton: React.FC<Props> = ({
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH,
MIN_PLAYBACK_WIDTH
)
: 0;
}
@@ -262,7 +257,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item],
[item]
);
useAnimatedReaction(
@@ -275,7 +270,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom],
[colorAtom]
);
useEffect(() => {
@@ -296,7 +291,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary],
[startColor.value.primary, endColor.value.primary]
),
}));
@@ -304,7 +299,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary],
[startColor.value.primary, endColor.value.primary]
),
}));
@@ -312,7 +307,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value],
[startWidth.value, targetWidth.value]
)}%`,
}));
@@ -320,7 +315,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text],
[startColor.value.text, endColor.value.text]
),
}));
/**
@@ -328,62 +323,75 @@ export const PlayButton: React.FC<Props> = ({
*/
return (
<TouchableOpacity
disabled={!item}
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative"}
{...props}
>
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-xl'
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
<View>
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className={`relative`}
{...props}
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
</View>
</TouchableOpacity>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
);
};

View File

@@ -1,16 +1,14 @@
import { useHaptic } from "@/hooks/useHaptic";
import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
@@ -22,8 +20,10 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -57,14 +57,13 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string) => {
(q: string, bitrateValue: number | undefined) => {
router.push(`/player/direct-player?${q}`);
},
[router],
[router]
);
const onPress = () => {
console.log("onpress");
const onPress = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
@@ -78,18 +77,26 @@ export const PlayButton: React.FC<Props> = ({
});
const queryString = queryParams.toString();
goToPlayer(queryString);
goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
};
}, [
item,
settings,
api,
user,
router,
showActionSheetWithOptions,
selectedOptions,
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH,
MIN_PLAYBACK_WIDTH
)
: 0;
}
@@ -106,7 +113,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item],
[item]
);
useAnimatedReaction(
@@ -119,7 +126,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom],
[colorAtom]
);
useEffect(() => {
@@ -140,7 +147,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary],
[startColor.value.primary, endColor.value.primary]
),
}));
@@ -148,7 +155,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary],
[startColor.value.primary, endColor.value.primary]
),
}));
@@ -156,7 +163,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value],
[startWidth.value, targetWidth.value]
)}%`,
}));
@@ -164,7 +171,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text],
[startColor.value.text, endColor.value.text]
),
}));
/**
@@ -172,55 +179,69 @@ export const PlayButton: React.FC<Props> = ({
*/
return (
<TouchableOpacity
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative"}
{...props}
>
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-xl'
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
<View>
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className={`relative`}
{...props}
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
</View>
</TouchableOpacity>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
);
};

View File

@@ -1,194 +0,0 @@
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
type BaseItemDto,
PlayCommand,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { useAtomValue } from "jotai";
import React, { useState } from "react";
import {
FlatList,
Modal,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { Loader } from "./Loader";
import { RoundButton } from "./RoundButton";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
size?: "default" | "large";
}
export const PlayInRemoteSessionButton: React.FC<Props> = ({
item,
...props
}) => {
const [modalVisible, setModalVisible] = useState(false);
const api = useAtomValue(apiAtom);
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
const handlePlayInSession = async (sessionId: string) => {
if (!api || !item.Id) return;
try {
console.log(`Playing ${item.Name} in session ${sessionId}`);
getSessionApi(api).play({
sessionId,
itemIds: [item.Id],
playCommand: PlayCommand.PlayNow,
});
setModalVisible(false);
} catch (error) {
console.error("Error playing in remote session:", error);
}
};
return (
<View {...props}>
<RoundButton
icon='play-circle-outline'
onPress={() => setModalVisible(true)}
size={props.size}
/>
<Modal
animationType='slide'
transparent={true}
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Select Session</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
<View style={styles.modalContent}>
{isLoading ? (
<View style={styles.loadingContainer}>
<Loader />
</View>
) : !sessions || sessions.length === 0 ? (
<Text style={styles.noSessionsText}>
No active sessions found
</Text>
) : (
<FlatList
data={sessions}
keyExtractor={(session) => session.Id || "unknown"}
renderItem={({ item: session }) => (
<TouchableOpacity
style={styles.sessionItem}
onPress={() => handlePlayInSession(session.Id || "")}
>
<View style={styles.sessionInfo}>
<Text style={styles.sessionName}>
{session.DeviceName}
</Text>
<Text style={styles.sessionDetails}>
{session.UserName} {session.Client}
</Text>
{session.NowPlayingItem && (
<Text style={styles.nowPlaying} numberOfLines={1}>
Now playing:{" "}
{session.NowPlayingItem.SeriesName
? `${session.NowPlayingItem.SeriesName} :`
: ""}
{session.NowPlayingItem.Name}
</Text>
)}
</View>
<Ionicons name='play-sharp' size={20} color='#888' />
</TouchableOpacity>
)}
contentContainerStyle={styles.listContent}
/>
)}
</View>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
modalView: {
width: "90%",
maxHeight: "80%",
backgroundColor: "#1c1c1c",
borderRadius: 20,
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
modalContent: {
flex: 1,
},
modalTitle: {
fontSize: 18,
fontWeight: "600",
},
loadingContainer: {
padding: 40,
alignItems: "center",
},
noSessionsText: {
padding: 40,
textAlign: "center",
color: "#888",
},
listContent: {
paddingVertical: 8,
},
sessionItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
sessionInfo: {
flex: 1,
},
sessionName: {
fontSize: 16,
fontWeight: "500",
marginBottom: 4,
},
sessionDetails: {
fontSize: 13,
opacity: 0.7,
marginBottom: 2,
},
nowPlaying: {
fontSize: 12,
opacity: 0.5,
fontStyle: "italic",
},
});

View File

@@ -1,8 +1,8 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import type React from "react";
import { View, type ViewProps } from "react-native";
import React from "react";
import { View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
@@ -18,7 +18,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
});
})
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
@@ -51,9 +51,9 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
<RoundButton
fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
onPress={async () => {
console.log(allPlayed);
await markAsPlayedStatus(!allPlayed);
await markAsPlayedStatus(!allPlayed)
}}
size={props.size}
/>

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