Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Kim
017bd4d074 Fixed file paths in the controls directory 2025-02-16 14:06:30 +11:00
herrrta
8b3141dfc6 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 15:16:25 -05:00
372 changed files with 11629 additions and 24448 deletions

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(bun install:*)",
"Bash(bunx expo prebuild:*)",
"Bash(bunx expo run:*)",
"Bash(npx expo prebuild:*)",
"Bash(npx expo run:*)",
"Bash(xcodebuild:*)"
],
"deny": []
}
}

View File

@@ -1,7 +0,0 @@
---
description: Don't write code directly in the ios folder.
globs:
alwaysApply: true
---
We never write code directly in the ios folder. This code is generated by expo plugins.

View File

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

View File

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

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

1
.gitattributes vendored
View File

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

View File

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

View File

@@ -1,102 +0,0 @@
name: 🤖 Android APK Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build-android:
runs-on: ubuntu-24.04
name: 🏗️ Build Android APK
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 💾 Cache node_modules
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: node_modules
key: ${{ runner.os }}-${{ runner.arch }}-modules-latest-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-modules-latest-develop
${{ runner.os }}-${{ runner.arch }}-modules-develop
${{ runner.os }}-modules-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-develop
- name: 🛠️ Generate project files
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
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-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
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,81 +0,0 @@
name: 🤖 iOS IPA Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build-ios:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
runs-on: macos-15
name: 🏗️ Build iOS IPA
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: 🚀 Build iOS app
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
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-ios-${{ matrix.target }}-ipa-${{ 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9

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,121 +0,0 @@
name: 🚦 Security & Quality Gate
on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
branches: [develop]
permissions:
contents: read
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@fdd4d3ddf614fbcd8c29e4b106d3bbe0cb2c605d # v6.0.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
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@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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 }}
expo-doctor:
name: 🚑 Expo Doctor Check
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- "lint"
- "check"
- "format"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile
- name: "🚨 Run ${{ matrix.command }}"
run: bun run ${{ matrix.command }}

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@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
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

10
.gitignore vendored
View File

@@ -18,7 +18,9 @@ expo-env.d.ts
Streamyfin.app Streamyfin.app
build-*
*.mp4 *.mp4
build-*
Streamyfin.app Streamyfin.app
package-lock.json package-lock.json
@@ -39,10 +41,4 @@ credentials.json
.vscode/ .vscode/
.idea/ .idea/
.ruby-lsp .ruby-lsp
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.env
.env.local
*.aab
/version-backup-*

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]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120,
"[swift]": { "[swift]": {
"editor.defaultFormatter": "sswg.swift-lang" "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

128
README.md
View File

@@ -1,70 +1,61 @@
# 📺 Streamyfin
<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> <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 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.
<p align="center"> <div style="display: flex; flex-direction: row; gap: 8px">
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%"> <img width=150 src="./assets/images/screenshots/screenshot1.png" />
</p> <img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.** <img width=159 src="./assets/images/jellyseerr.PNG"/>
</div>
---
<p align="center">
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
&nbsp;
<img src="./assets/images/jellyseerr.PNG" width="23%">
</p>
## 🌟 Features ## 🌟 Features
- 🚀 **Skip Intro / Credits Support** - 🚀 **Skip Intro / Credits Support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. - 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin. - 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app. - 🤖 **Jellyseerr integration**: Request media directly in the app.
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
## 🧪 Experimental Features ## 🧪 Experimental Features
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them. Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
### 📥 Downloading ### Downloading
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
### 🎥 Chromecast ### 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 ### 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 - Choose the default languages
- Set download method and search provider - Set download method and search provider
- Customize home screen - Customize homescreen
- And much more... - And more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### 🔍 Jellysearch ### Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## 🛣️ Roadmap for V1 ## Roadmap for V1
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
## 📥 Get it now ## Get it now
<div style="display: flex; gap: 5px;"> <div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a> <a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
@@ -73,9 +64,9 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android. Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
### 🧪 Beta testing ### Beta testing
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you. 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. **Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
@@ -90,14 +81,13 @@ To access the Streamyfin beta, you need to subscribe to the Member tier (or high
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas. We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
### 👨‍💻 Development info ### Development info
1. Use node `>20` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && 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/) 3. Make sure you have xcode and/or android studio installed.
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild` 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. 5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.
@@ -116,106 +106,98 @@ Key points of the MPL-2.0:
- You must disclose your source code for any modifications to the covered files - You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses - Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license - MPL-licensed components must remain under the MPL, but the larger work can be under a different license
- For the full text of the license, please see the LICENSE file in this repository - For the full text of the license, please see the LICENSE file in this repository.
## 🌐 Connect with Us ## 🌐 Connect with Us
Join our Discord: [![](https://dcbadge.limes.pink/api/server/https://discord.gg/BuGG9ZNhaE)](https://discord.gg/BuGG9ZNhaE) Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
Need support or have questions: If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here. - GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) - 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 ## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries. Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
## ✨ Acknowledgements ## ✨ Acknowledgements
We would like to thank the Jellyfin team for their great software and awesome support on discord. ### Core Developers
Special shoutout to the JF official clients for being an inspiration to ours.
### 🏆 Core Developers
Thanks to the following contributors for their significant contributions: Thanks to the following contributors for their significant contributions:
<div align="left">
<table> <table>
<tr> <tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center"> <td align="center">
<a href="https://github.com/Alexk2309"> <a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub> <br /><sub><b>@Alexk2309</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/herrrta"> <a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub> <br /><sub><b>@herrrta</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/lostb1t"> <a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub> <br /><sub><b>@lostb1t</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/Simon-Eklundh"> <a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub> <br /><sub><b>@Simon-Eklundh</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/topiga"> <a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub> <br /><sub><b>@topiga</b></sub>
</a> </a>
</td> </td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/simoncaron"> <a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub> <br /><sub><b>@simoncaron</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/jakequade"> <a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub> <br /><sub><b>@jakequade</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/Ryan0204"> <a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub> <br /><sub><b>@Ryan0204</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/retardgerman"> <a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub> <br /><sub><b>@retardgerman</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/whoopsi-daisy"> <a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub> <br /><sub><b>@whoopsi-daisy</b></sub>
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
</div>
And all other developers who have contributed to Streamyfin, thank you for your contributions. And all other developers who have contributed to Streamyfin, thank you for your contributions.
@@ -226,12 +208,6 @@ I'd also like to thank the following people and projects for their contributions
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project. - [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
- The Jellyfin devs for always being helpful in the Discord. - The Jellyfin devs for always being helpful in the Discord.
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) [![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)
## ⚠️ Disclaimer
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)

View File

@@ -1,17 +1,11 @@
module.exports = ({ config }) => { module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV != "1") {
config.plugins.push([ config.plugins.push([
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
} }
return { return {
android: {
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
},
...config, ...config,
}; };
}; };

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.30.2", "version": "0.26.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -27,34 +27,26 @@
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
}, },
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin", "bundleIdentifier": "com.fredrikburmester.streamyfin"
"icon": {
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
},
"appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 58, "versionCode": 53,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/adaptive_icon.png"
"monochromeImage": "./assets/images/icon-android-themed.png",
"backgroundColor": "#2E2E2E"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
"android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS" "android.permission.WRITE_SETTINGS"
], ]
"googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv", "@react-native-tvos/config-tv",
"expo-router", "expo-router",
"expo-font", "expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[ [
"react-native-video", "react-native-video",
{ {
@@ -114,6 +106,7 @@
} }
} }
], ],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
@@ -122,19 +115,10 @@
"expo-splash-screen", "expo-splash-screen",
{ {
"backgroundColor": "#2e2e2e", "backgroundColor": "#2e2e2e",
"image": "./assets/images/icon-ios-plain.png", "image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100 "imageWidth": 100
} }
], ]
[
"expo-notifications",
{
"icon": "./assets/images/notification.png",
"color": "#9333EA"
}
],
"./plugins/with-runtime-framework-headers.js",
"react-native-bottom-tabs"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
@@ -147,7 +131,7 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68" "projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
} }
}, },
"owner": "streamyfin", "owner": "fredrikburmester",
"runtimeVersion": { "runtimeVersion": {
"policy": "appVersion" "policy": "appVersion"
}, },

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites"; import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import React, { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function favorites() { export default function favorites() {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
@@ -18,7 +18,7 @@ export default function favorites() {
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior="automatic"
refreshControl={ refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} /> <RefreshControl refreshing={loading} onRefresh={refetch} />
} }
@@ -28,7 +28,7 @@ export default function favorites() {
paddingBottom: 16, paddingBottom: 16,
}} }}
> >
<View className='my-4'> <View className="my-4">
<Favorites /> <Favorites />
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -1,41 +1,39 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
import { useAtom } from "jotai"; import { useTranslation } from "react-i18next";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
import { userAtom } from "@/providers/JellyfinProvider";
export default function IndexLayout() { export default function IndexLayout() {
const _router = useRouter(); const router = useRouter();
const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
name='index' name="index"
options={{ options={{
headerShown: !Platform.isTV, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.home"), headerTitle: t("tabs.home"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerLargeStyle: { headerLargeStyle: {
backgroundColor: "black", backgroundColor: "black",
}, },
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
<View className='flex flex-row items-center space-x-2'> <View className="flex flex-row items-center space-x-2">
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast /> <Chromecast.Chromecast />
{user?.Policy?.IsAdministrator && <SessionsButton />} <TouchableOpacity
<SettingsButton /> onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</> </>
)} )}
</View> </View>
@@ -43,55 +41,55 @@ export default function IndexLayout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name='downloads/index' name="downloads/index"
options={{ options={{
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='downloads/[seriesId]' name="downloads/[seriesId]"
options={{ options={{
title: t("home.downloads.tvseries"), title: t("home.downloads.tvseries"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='sessions/index' name="settings"
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen
name='settings'
options={{ options={{
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name="settings/optimized-server/page"
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/jellyseerr/page' name="settings/marlin-search/page"
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/hide-libraries/page' name="settings/jellyseerr/page"
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/logs/page' name="settings/hide-libraries/page"
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='intro/page' name="settings/logs/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="intro/page"
options={{ options={{
headerShown: false, headerShown: false,
title: "", title: "",
@@ -102,50 +100,15 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} /> <Stack.Screen key={name} name={name} options={options} />
))} ))}
<Stack.Screen <Stack.Screen
name='collections/[collectionId]' name="collections/[collectionId]"
options={{ options={{
title: "", title: "",
headerShown: true, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />
</Stack> </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 { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard"; import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
SeasonDropdown, SeasonDropdown,
type SeasonIndexState, SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -21,53 +21,23 @@ export default function page() {
}; };
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>( const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}, {}
); );
const { getDownloadedItems, deleteItems } = useDownload(); const { downloadedFiles, deleteItems } = useDownload();
const series = useMemo(() => { const series = useMemo(() => {
try { try {
return ( return (
getDownloadedItems() downloadedFiles
?.filter((f) => f.item.SeriesId === seriesId) ?.filter((f) => f.item.SeriesId == seriesId)
?.sort( ?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!, (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
) || [] ) || []
); );
} catch { } catch {
return []; return [];
} }
}, [getDownloadedItems]); }, [downloadedFiles]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
const groups: Record<number, BaseItemDto[]> = {};
series.forEach((episode) => {
const seasonNumber = episode.item.ParentIndexNumber;
if (seasonNumber !== undefined && seasonNumber !== null) {
if (!groups[seasonNumber]) {
groups[seasonNumber] = [];
}
groups[seasonNumber].push(episode.item);
}
});
// Sort episodes within each season
Object.values(groups).forEach((episodes) => {
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
});
return groups;
}, [series]);
// Get unique seasons (just the season numbers, sorted)
const uniqueSeasons = useMemo(() => {
const seasonNumbers = Object.keys(seasonGroups)
.map(Number)
.sort((a, b) => a - b);
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
}, [seasonGroups]);
const seasonIndex = const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
@@ -75,14 +45,26 @@ export default function page() {
""; "";
const groupBySeason = useMemo<BaseItemDto[]>(() => { const groupBySeason = useMemo<BaseItemDto[]>(() => {
return seasonGroups[Number(seasonIndex)] ?? []; const seasons: Record<string, BaseItemDto[]> = {};
}, [seasonGroups, seasonIndex]);
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return (
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
[]
);
}, [series, seasonIndex]);
const initialSeasonIndex = useMemo( const initialSeasonIndex = useMemo(
() => () =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber, series?.[0]?.item?.ParentIndexNumber,
[groupBySeason], [groupBySeason]
); );
useEffect(() => { useEffect(() => {
@@ -110,17 +92,17 @@ export default function page() {
onPress: () => deleteItems(groupBySeason), onPress: () => deleteItems(groupBySeason),
style: "destructive", style: "destructive",
}, },
], ]
); );
}, [groupBySeason]); }, [groupBySeason]);
return ( return (
<View className='flex-1'> <View className="flex-1">
{series.length > 0 && ( {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 <SeasonDropdown
item={series[0].item} item={series[0].item}
seasons={uniqueSeasons} seasons={series.map((s) => s.item)}
state={seasonIndexState} state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!} initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => { onSelect={(season) => {
@@ -130,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'> <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> <Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View> </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}> <TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' /> <Ionicons name="trash" size={20} color="white" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)} )}
<ScrollView key={seasonIndex} className='px-4'> <ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => ( {groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} /> <EpisodeCard key={index} item={episode} />
))} ))}

View File

@@ -1,74 +1,43 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { 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 { import {
BottomSheetBackdrop, BottomSheetBackdrop,
type BottomSheetBackdropProps, BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
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 { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const { const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const router = useRouter(); const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false);
const migration_20241124 = () => {
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
t("home.downloads.new_app_version_requires_re_download_description"),
[
{
text: t("home.downloads.back"),
onPress: () => {
setShowMigration(false);
router.back();
},
},
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => {
await deleteAllFiles();
setShowMigration(false);
},
},
],
);
};
const downloadedFiles = getDownloadedItems();
const movies = useMemo(() => { const movies = useMemo(() => {
try { try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch { } catch {
setShowMigration(true); migration_20241124();
return []; return [];
} }
}, [downloadedFiles]); }, [downloadedFiles]);
@@ -76,7 +45,7 @@ export default function page() {
const groupedBySeries = useMemo(() => { const groupedBySeries = useMemo(() => {
try { try {
const episodes = downloadedFiles?.filter( const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode", (f) => f.item.Type === "Episode"
); );
const series: { [key: string]: DownloadedItem[] } = {}; const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => { episodes?.forEach((e) => {
@@ -85,11 +54,13 @@ export default function page() {
}); });
return Object.values(series); return Object.values(series);
} catch { } catch {
setShowMigration(true); migration_20241124();
return []; return [];
} }
}, [downloadedFiles]); }, [downloadedFiles]);
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
@@ -100,30 +71,16 @@ export default function page() {
}); });
}, [downloadedFiles]); }, [downloadedFiles]);
useEffect(() => {
if (showMigration) {
migration_20241124();
}
}, [showMigration]);
const deleteMovies = () => const deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
toast.success(
t("home.downloads.toasts.deleted_all_movies_successfully"),
),
)
.catch((reason) => { .catch((reason) => {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
}); });
const deleteShows = () => const deleteShows = () =>
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
toast.success(
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
),
)
.catch((reason) => { .catch((reason) => {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
@@ -133,29 +90,33 @@ export default function page() {
return ( return (
<> <>
<View style={{ flex: 1 }}> <ScrollView
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'> contentContainerStyle={{
<View className='py-4'> paddingLeft: insets.left,
<View className='mb-4 flex flex-col space-y-4 px-4'> paddingRight: insets.right,
<View className='bg-neutral-900 p-4 rounded-2xl'> paddingBottom: 100,
<Text className='text-lg font-bold'> }}
{t("home.downloads.queue")} >
</Text> <View className="py-4">
<Text className='text-xs opacity-70 text-red-600'> <View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600">
{t("home.downloads.queue_hint")} {t("home.downloads.queue_hint")}
</Text> </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) => ( {queue.map((q, index) => (
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`) 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} key={index}
> >
<View> <View>
<Text className='font-semibold'>{q.item.Name}</Text> <Text className="font-semibold">{q.item.Name}</Text>
<Text className='text-xs opacity-50'> <Text className="text-xs opacity-50">
{q.item.Type} {q.item.Type}
</Text> </Text>
</View> </View>
@@ -168,86 +129,74 @@ export default function page() {
}); });
}} }}
> >
<Ionicons name='close' size={24} color='red' /> <Ionicons name="close" size={24} color="red" />
</TouchableOpacity> </TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
{queue.length === 0 && ( {queue.length === 0 && (
<Text className='opacity-50'> <Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
{t("home.downloads.no_items_in_queue")}
</Text>
)} )}
</View> </View>
)}
<ActiveDownloads /> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View> </View>
</ScrollView>
</View> {movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View
className="mb-2 last:mb-0"
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
@@ -266,14 +215,14 @@ export default function page() {
)} )}
> >
<BottomSheetView> <BottomSheetView>
<View className='p-4 space-y-4 mb-4'> <View className="p-4 space-y-4 mb-4">
<Button color='purple' onPress={deleteMovies}> <Button color="purple" onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")} {t("home.downloads.delete_all_movies_button")}
</Button> </Button>
<Button color='purple' onPress={deleteShows}> <Button color="purple" onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")} {t("home.downloads.delete_all_tvseries_button")}
</Button> </Button>
<Button color='red' onPress={deleteAllMedia}> <Button color="red" onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")} {t("home.downloads.delete_all_button")}
</Button> </Button>
</View> </View>
@@ -282,3 +231,23 @@ export default function page() {
</> </>
); );
} }
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
t("home.downloads.new_app_version_requires_re_download_description"),
[
{
text: t("home.downloads.back"),
onPress: () => router.back(),
},
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
);
}

View File

@@ -1,5 +1,498 @@
import { HomeIndex } from "@/components/settings/HomeIndex"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "@/providers/SplashScreenProvider";
export default function page() { type ScrollingCollectionListSection = {
return <HomeIndex />; type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
if (!Platform.isTV) {
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
}
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
// show splash screen until query loaded
useSplashScreenLoading(l1);
const splashScreenVisible = useSplashScreenVisible();
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
// this spinner should only show up, when user navigates here
// on launch the splash screen is used for loading
if (l1 && !splashScreenVisible)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
}
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
} }

View File

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

View File

@@ -1,546 +0,0 @@
import { 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 { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
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";
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,
}: 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

@@ -1,16 +1,8 @@
import { useNavigation, useRouter } from "expo-router"; import { Platform } from "react-native";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles"; 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 { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings"; import { OtherSettings } from "@/components/settings/OtherSettings";
@@ -18,16 +10,24 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic"; import { useJellyfin } from "@/providers/JellyfinProvider";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
export default function settings() { export default function settings() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
@@ -45,7 +45,7 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className='text-red-600'> <Text className="text-red-600">
{t("home.settings.log_out_button")} {t("home.settings.log_out_button")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -60,27 +60,24 @@ export default function settings() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='p-4 flex flex-col gap-y-4'> <View className="p-4 flex flex-col gap-y-4">
<UserInfo /> <UserInfo />
<QuickConnect className="mb-4" />
<QuickConnect className='mb-4' />
<MediaProvider> <MediaProvider>
<MediaToggles className='mb-4' /> <MediaToggles className="mb-4" />
<AudioToggles className='mb-4' /> <AudioToggles className="mb-4" />
<SubtitleToggles className='mb-4' /> <SubtitleToggles className="mb-4" />
</MediaProvider> </MediaProvider>
<OtherSettings /> <OtherSettings />
<DownloadSettings /> {!Platform.isTV && <DownloadSettings />}
<PluginSettings /> <PluginSettings />
<AppLanguageSelector /> <AppLanguageSelector />
<ChromecastSettings />
<ListGroup title={"Intro"}> <ListGroup title={"Intro"}>
<ListItem <ListItem
onPress={() => { onPress={() => {
@@ -89,7 +86,7 @@ export default function settings() {
title={t("home.settings.intro.show_intro")} title={t("home.settings.intro.show_intro")}
/> />
<ListItem <ListItem
textColor='red' textColor="red"
onPress={() => { onPress={() => {
storage.set("hasShownIntro", false); storage.set("hasShownIntro", false);
}} }}
@@ -97,7 +94,7 @@ export default function settings() {
/> />
</ListGroup> </ListGroup>
<View className='mb-4'> <View className="mb-4">
<ListGroup title={t("home.settings.logs.logs_title")}> <ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem <ListItem
onPress={() => router.push("/settings/logs/page")} onPress={() => router.push("/settings/logs/page")}
@@ -105,7 +102,7 @@ export default function settings() {
title={t("home.settings.logs.logs_title")} title={t("home.settings.logs.logs_title")}
/> />
<ListItem <ListItem
textColor='red' textColor="red"
onPress={onClearLogsClicked} onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")} title={t("home.settings.logs.delete_all_logs")}
/> />

View File

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

View File

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

View File

@@ -1,160 +1,35 @@
import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton"; import { useLog } from "@/utils/log";
import { Loader } from "@/components/Loader"; import { ScrollView, View } from "react-native";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log"; import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const navigation = useNavigation();
const { logs } = useLog(); const { logs } = useLog();
const { t } = useTranslation(); const { t } = useTranslation();
const orderFilterId = useId();
const levelsFilterId = useId();
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 ( return (
<> <ScrollView className="p-4">
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> <View className="flex flex-col space-y-2">
<FilterButton {logs?.map((log, index) => (
id={orderFilterId} <View key={index} className="bg-neutral-900 rounded-xl p-3">
queryKey='log' <Text
queryFn={async () => ["asc", "desc"]} className={`
set={(values) => setOrder(values[0])} mb-1
values={[order]} ${log.level === "INFO" && "text-blue-500"}
title={t("library.filters.sort_order")} ${log.level === "ERROR" && "text-red-500"}
renderItemLabel={(order) => t(`library.filters.${order}`)} `}
showSearch={false} >
/> {log.level}
<FilterButton
id={levelsFilterId}
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 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")}
</Text> </Text>
)} <Text uiTextView selectable className="text-xs">
</View> {log.message}
</ScrollView> </Text>
</> </View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
)}
</View>
</ScrollView>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,11 @@
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 { useLocalSearchParams } from "expo-router";
import type React from "react"; import { useAtom } from "jotai";
import { useEffect } from "react"; import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import Animated, { import Animated, {
runOnJS, runOnJS,
@@ -9,18 +13,30 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Text } from "@/components/common/Text"; import { useTranslation } from "react-i18next";
import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery";
const Page: React.FC = () => { const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation(); const { t } = useTranslation();
const { offline } = useLocalSearchParams() as { offline?: string }; const { data: item, isError } = useQuery({
const isOffline = offline === "true"; queryKey: ["item", id],
queryFn: async () => {
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
userId: user?.Id,
});
const { data: item, isError } = useItemQuery(id, isOffline); return res.data;
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
@@ -59,38 +75,38 @@ const Page: React.FC = () => {
if (isError) if (isError)
return ( 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> <Text>{t("item_card.could_not_load_item")}</Text>
</View> </View>
); );
return ( return (
<View className='flex flex-1 relative'> <View className="flex flex-1 relative">
<Animated.View <Animated.View
pointerEvents={"none"} pointerEvents={"none"}
style={[animatedStyle]} 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 <View
style={{ style={{
height: item?.Type === "Episode" ? 300 : 450, height: item?.Type === "Episode" ? 300 : 450,
}} }}
className='bg-transparent rounded-lg mb-4 w-full' className="bg-transparent rounded-lg mb-4 w-full"
/> ></View>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' /> <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 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 className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
<View className='flex flex-row space-x-1 mb-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>
<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>
<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>
</View> </View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' /> <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 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 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 className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
</Animated.View> </Animated.View>
{item && <ItemContent item={item} isOffline={isOffline} />} {item && <ItemContent item={item} />}
</View> </View>
); );
}; };

View File

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

View File

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

View File

@@ -1,19 +1,3 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type 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 { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
@@ -27,44 +11,56 @@ import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { import {
type IssueType, IssueType,
IssueTypeName, IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue"; } from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
MovieResult, import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
TvResult, import { useTranslation } from "react-i18next";
} from "@/utils/jellyseerr/server/models/Search"; import { Ionicons } from "@expo/vector-icons";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } = const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as { params as unknown as {
mediaTitle: string; mediaTitle: string;
releaseYear: number; releaseYear: number;
canRequest: string; canRequest: string;
posterSrc: string; posterSrc: string;
mediaType: MediaType; } & Partial<MovieResult | TvResult>;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation(); const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr(); const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null); const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -75,7 +71,7 @@ const Page: React.FC = () => {
refetch, refetch,
} = useQuery({ } = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id, enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id], queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnReconnect: true, refetchOnReconnect: true,
@@ -83,9 +79,9 @@ const Page: React.FC = () => {
retryOnMount: true, retryOnMount: true,
refetchInterval: 0, refetchInterval: 0,
queryFn: async () => { queryFn: async () => {
return mediaType === MediaType.MOVIE return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!) ? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!); : jellyseerrApi?.tvDetails(result.id!!);
}, },
}); });
@@ -100,7 +96,7 @@ const Page: React.FC = () => {
appearsOnIndex={0} appearsOnIndex={0}
/> />
), ),
[], []
); );
const submitIssue = useCallback(() => { const submitIssue = useCallback(() => {
@@ -115,18 +111,10 @@ const Page: React.FC = () => {
} }
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
_setRequestBody(body);
advancedReqModalRef?.current?.present?.();
},
[requestBody, _setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => { const request = useCallback(async () => {
const body: MediaRequestBody = { const body: MediaRequestBody = {
mediaId: Number(result.id!), mediaId: Number(result.id!!),
mediaType: mediaType!, mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId, tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0) ?.filter?.((s) => s.seasonNumber !== 0)
@@ -134,7 +122,7 @@ const Page: React.FC = () => {
}; };
if (hasAdvancedRequestPermission) { if (hasAdvancedRequestPermission) {
setRequestBody(body); advancedReqModalRef?.current?.present?.(body);
return; return;
} }
@@ -144,15 +132,15 @@ const Page: React.FC = () => {
const isAnime = useMemo( const isAnime = useMemo(
() => () =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) && (details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
mediaType === MediaType.TV, result.mediaType === MediaType.TV,
[details], [details]
); );
useEffect(() => { useEffect(() => {
if (details) { if (details) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'> <TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<ItemActions item={details} /> <ItemActions item={details} />
</TouchableOpacity> </TouchableOpacity>
), ),
@@ -162,14 +150,14 @@ const Page: React.FC = () => {
return ( return (
<View <View
className='flex-1 relative' className="flex-1 relative"
style={{ style={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<ParallaxScrollView <ParallaxScrollView
className='flex-1 opacity-100' className="flex-1 opacity-100"
headerHeight={300} headerHeight={300}
headerImage={ headerImage={
<View> <View>
@@ -184,7 +172,7 @@ const Page: React.FC = () => {
source={{ source={{
uri: jellyseerrApi?.imageProxy( uri: jellyseerrApi?.imageProxy(
result.backdropPath, result.backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces"
), ),
}} }}
/> />
@@ -194,12 +182,12 @@ const Page: React.FC = () => {
width: "100%", width: "100%",
height: "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 <Ionicons
name='image-outline' name="image-outline"
size={24} size={24}
color='white' color="white"
style={{ opacity: 0.4 }} style={{ opacity: 0.4 }}
/> />
</View> </View>
@@ -207,27 +195,23 @@ const Page: React.FC = () => {
</View> </View>
} }
> >
<View className='flex flex-col'> <View className="flex flex-col">
<View className='space-y-4'> <View className="space-y-4">
<View className='px-4'> <View className="px-4">
<View className='flex flex-row justify-between w-full'> <View className="flex flex-row justify-between w-full">
<View className='flex flex-col w-56'> <View className="flex flex-col w-56">
<JellyserrRatings <JellyserrRatings result={result as MovieResult | TvResult} />
result={ <Text
result as uiTextView
| MovieResult selectable
| TvResult className="font-bold text-2xl mb-1"
| MovieDetails >
| TvDetails
}
/>
<Text selectable className='font-bold text-2xl mb-1'>
{mediaTitle} {mediaTitle}
</Text> </Text>
<Text className='opacity-50'>{releaseYear}</Text> <Text className="opacity-50">{releaseYear}</Text>
</View> </View>
<Image <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"} cachePolicy={"memory-disk"}
transition={300} transition={300}
source={{ source={{
@@ -235,82 +219,48 @@ const Page: React.FC = () => {
}} }}
/> />
</View> </View>
<View> <View className="mb-4">
<GenreTags genres={details?.genres?.map((g) => g.name) || []} /> <GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View> </View>
{isLoading || isFetching ? ( {isLoading || isFetching ? (
<Button <Button loading={true} disabled={true} color="purple"></Button>
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
) : canRequest ? ( ) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'> <Button color="purple" onPress={request}>
{t("jellyseerr.request_button")} {t("jellyseerr.request_button")}
</Button> </Button>
) : ( ) : (
details?.mediaInfo?.jellyfinMediaId && ( <Button
<View className='flex flex-row space-x-2 mt-4'> className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
{!Platform.isTV && ( color="transparent"
<Button onPress={() => bottomSheetModalRef?.current?.present()}
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100' iconLeft={
color='transparent' <Ionicons name="warning-outline" size={24} color="white" />
onPress={() => bottomSheetModalRef?.current?.present()} }
iconLeft={ style={{
<Ionicons borderWidth: 1,
name='warning-outline' borderStyle: "solid",
size={20} }}
color='white' >
/> {t("jellyseerr.report_issue_button")}
} </Button>
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>
)
)} )}
<OverviewText text={result.overview} className='mt-4' /> <OverviewText text={result.overview} className="mt-4" />
</View> </View>
{mediaType === MediaType.TV && ( {result.mediaType === MediaType.TV && (
<JellyseerrSeasons <JellyseerrSeasons
isLoading={isLoading || isFetching} isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails} details={details as TvDetails}
refetch={refetch} refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission} hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) => setRequestBody(data)} onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
}
/> />
)} )}
<DetailFacts <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} details={details}
/> />
<Cast details={details} /> <Cast details={details} />
@@ -319,107 +269,101 @@ const Page: React.FC = () => {
</ParallaxScrollView> </ParallaxScrollView>
<RequestModal <RequestModal
ref={advancedReqModalRef} ref={advancedReqModalRef}
requestBody={requestBody}
title={mediaTitle} title={mediaTitle}
id={result.id!} id={result.id!!}
type={mediaType} type={result.mediaType as MediaType}
isAnime={isAnime} isAnime={isAnime}
onRequested={() => { onRequested={() => {
_setRequestBody(undefined);
advancedReqModalRef?.current?.close(); advancedReqModalRef?.current?.close();
refetch(); refetch();
}} }}
onDismiss={() => _setRequestBody(undefined)}
/> />
{!Platform.isTV && ( <BottomSheetModal
// This is till it's fixed because the menu isn't selectable on TV ref={bottomSheetModalRef}
<BottomSheetModal enableDynamicSizing
ref={bottomSheetModalRef} handleIndicatorStyle={{
enableDynamicSizing backgroundColor: "white",
handleIndicatorStyle={{ }}
backgroundColor: "white", backgroundStyle={{
}} backgroundColor: "#171717",
backgroundStyle={{ }}
backgroundColor: "#171717", backdropComponent={renderBackdrop}
}} >
backdropComponent={renderBackdrop} <BottomSheetView>
> <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<BottomSheetView> <View>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <Text className="font-bold text-2xl text-neutral-100">
<View> {t("jellyseerr.whats_wrong")}
<Text className='font-bold text-2xl text-neutral-100'> </Text>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View> </View>
</BottomSheetView> <View className="flex flex-col space-y-2 items-start">
</BottomSheetModal> <View className="flex flex-col">
)} <DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode="always"
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className="mt-auto" onPress={submitIssue} color="purple">
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View> </View>
); );
}; };

View File

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

View File

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

View File

@@ -1,17 +1,18 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() { export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const _insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { data: channels } = useQuery({ const { data: channels } = useQuery({
queryKey: ["livetv", "channels"], queryKey: ["livetv", "channels"],
@@ -30,13 +31,13 @@ export default function page() {
}); });
return ( return (
<View className='flex flex-1'> <View className="flex flex-1">
<FlashList <FlashList
data={channels?.Items} data={channels?.Items}
estimatedItemSize={76} estimatedItemSize={76}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View className='flex flex-row items-center px-4 mb-2'> <View className="flex flex-row items-center px-4 mb-2">
<View className='w-22 mr-4 rounded-lg overflow-hidden'> <View className="w-22 mr-4 rounded-lg overflow-hidden">
<ItemImage <ItemImage
style={{ style={{
aspectRatio: "1/1", aspectRatio: "1/1",
@@ -46,7 +47,7 @@ export default function page() {
item={item} item={item}
/> />
</View> </View>
<Text className='font-bold'>{item.Name}</Text> <Text className="font-bold">{item.Name}</Text>
</View> </View>
)} )}
/> />

View File

@@ -1,16 +1,23 @@
import { Ionicons } from "@expo/vector-icons";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage"; import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { HourHeader } from "@/components/livetv/HourHeader"; import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow"; import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import {
Button,
Dimensions,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30; const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
@@ -21,9 +28,17 @@ export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [date, _setDate] = useState<Date>(new Date()); const [date, setDate] = useState<Date>(new Date());
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const { data: guideInfo } = useQuery({
queryKey: ["livetv", "guideInfo"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getGuideInfo();
return res.data;
},
});
const { data: channels } = useQuery({ const { data: channels } = useQuery({
queryKey: ["livetv", "channels", currentPage], queryKey: ["livetv", "channels", currentPage],
queryFn: async () => { queryFn: async () => {
@@ -56,7 +71,7 @@ export default function page() {
MaxStartDate: endOfDay.toISOString(), MaxStartDate: endOfDay.toISOString(),
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(), MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
ChannelIds: channels?.Items?.map((c) => c.Id).filter( ChannelIds: channels?.Items?.map((c) => c.Id).filter(
Boolean, Boolean
) as string[], ) as string[],
ImageTypeLimit: 1, ImageTypeLimit: 1,
EnableImages: false, EnableImages: false,
@@ -85,7 +100,7 @@ export default function page() {
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior="automatic"
key={"home"} key={"home"}
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
@@ -102,16 +117,16 @@ export default function page() {
} }
/> />
<View className='flex flex-row'> <View className="flex flex-row">
<View className='flex flex-col w-[64px]'> <View className="flex flex-col w-[64px]">
<View <View
style={{ style={{
height: HOUR_HEIGHT, height: HOUR_HEIGHT,
}} }}
className='bg-neutral-800' className="bg-neutral-800"
/> ></View>
{channels?.Items?.map((c, i) => ( {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 <ItemImage
style={{ style={{
width: "100%", width: "100%",
@@ -133,9 +148,9 @@ export default function page() {
setScrollX(e.nativeEvent.contentOffset.x); setScrollX(e.nativeEvent.contentOffset.x);
}} }}
> >
<View className='flex flex-col'> <View className="flex flex-col">
<HourHeader height={HOUR_HEIGHT} /> <HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, _i) => ( {channels?.Items?.map((c, i) => (
<MemoizedLiveTVGuideRow <MemoizedLiveTVGuideRow
channel={c} channel={c}
programs={programs?.Items} programs={programs?.Items}
@@ -165,14 +180,14 @@ const PageButtons: React.FC<PageButtonsProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( 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 <TouchableOpacity
onPress={onPrevPage} onPress={onPrevPage}
disabled={currentPage === 1} disabled={currentPage === 1}
className='flex flex-row items-center' className="flex flex-row items-center"
> >
<Ionicons <Ionicons
name='chevron-back' name="chevron-back"
size={24} size={24}
color={currentPage === 1 ? "gray" : "white"} color={currentPage === 1 ? "gray" : "white"}
/> />
@@ -184,11 +199,11 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.previous")} {t("live_tv.previous")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text className='text-white'>Page {currentPage}</Text> <Text className="text-white">Page {currentPage}</Text>
<TouchableOpacity <TouchableOpacity
onPress={onNextPage} onPress={onNextPage}
disabled={isNextDisabled} disabled={isNextDisabled}
className='flex flex-row items-center' className="flex flex-row items-center"
> >
<Text <Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`} className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
@@ -196,7 +211,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.next")} {t("live_tv.next")}
</Text> </Text>
<Ionicons <Ionicons
name='chevron-forward' name="chevron-forward"
size={24} size={24}
color={isNextDisabled ? "gray" : "white"} color={isNextDisabled ? "gray" : "white"}
/> />

View File

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

View File

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

View File

@@ -1,13 +1,3 @@
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
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 { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites"; import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem"; import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -18,6 +8,15 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -50,7 +49,7 @@ const page: React.FC = () => {
quality: 90, quality: 90,
width: 1000, width: 1000,
}), }),
[item], [item]
); );
const logoUrl = useMemo( const logoUrl = useMemo(
@@ -59,7 +58,7 @@ const page: React.FC = () => {
api, api,
item, item,
}), }),
[item], [item]
); );
const { data: allEpisodes, isLoading } = useQuery({ const { data: allEpisodes, isLoading } = useQuery({
@@ -69,18 +68,10 @@ const page: React.FC = () => {
seriesId: item?.Id!, seriesId: item?.Id!,
userId: user?.Id!, userId: user?.Id!,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads fields: ["MediaSources", "MediaStreams", "Overview"],
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
return res?.data.Items || []; return res?.data.Items || [];
}, },
select: (data) =>
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
[...(data || [])].sort(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: 60, staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: !!api && !!user?.Id && !!item?.Id,
}); });
@@ -92,25 +83,23 @@ const page: React.FC = () => {
item && item &&
allEpisodes && allEpisodes &&
allEpisodes.length > 0 && ( allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'> <View className="flex flex-row items-center space-x-2">
<AddToFavorites item={item} /> <AddToFavorites item={item} type="series" />
{!Platform.isTV && ( <DownloadItems
<DownloadItems size="large"
size='large' title={t("item_card.download.download_series")}
title={t("item_card.download.download_series")} items={allEpisodes || []}
items={allEpisodes || []} MissingDownloadIconComponent={() => (
MissingDownloadIconComponent={() => ( <Ionicons name="download" size={22} color="white" />
<Ionicons name='download' size={22} color='white' /> )}
)} DownloadedIconComponent={() => (
DownloadedIconComponent={() => ( <Ionicons
<Ionicons name="checkmark-done-outline"
name='checkmark-done-outline' size={24}
size={24} color="#9333ea"
color='#9333ea' />
/> )}
)} />
/>
)}
</View> </View>
), ),
}); });
@@ -133,23 +122,25 @@ const page: React.FC = () => {
/> />
} }
logo={ logo={
logoUrl ? ( <>
<Image {logoUrl ? (
source={{ <Image
uri: logoUrl, source={{
}} uri: logoUrl,
style={{ }}
height: 130, style={{
width: "100%", height: 130,
resizeMode: "contain", width: "100%",
}} resizeMode: "contain",
/> }}
) : undefined />
) : null}
</>
} }
> >
<View className='flex flex-col pt-4'> <View className="flex flex-col pt-4">
<SeriesHeader item={item} /> <SeriesHeader item={item} />
<View className='mb-4'> <View className="mb-4">
<NextUp seriesId={seriesId} /> <NextUp seriesId={seriesId} />
</View> </View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} /> <SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />

View File

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

View File

@@ -1,11 +1,9 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
@@ -18,16 +16,16 @@ export default function IndexLayout() {
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
name='index' name="index"
options={{ options={{
headerShown: !Platform.isTV, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.library"), headerTitle: t("tabs.library"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerLargeStyle: { headerLargeStyle: {
backgroundColor: "black", backgroundColor: "black",
}, },
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => headerRight: () =>
!pluginSettings?.libraryOptions?.locked && !pluginSettings?.libraryOptions?.locked &&
@@ -35,9 +33,9 @@ export default function IndexLayout() {
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name="ellipsis-horizontal-outline"
size={24} size={24}
color='white' color="white"
/> />
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
@@ -52,9 +50,9 @@ export default function IndexLayout() {
<DropdownMenu.Label> <DropdownMenu.Label>
{t("library.options.display")} {t("library.options.display")}
</DropdownMenu.Label> </DropdownMenu.Label>
<DropdownMenu.Group key='display-group'> <DropdownMenu.Group key="display-group">
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'> <DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.display")} {t("library.options.display")}
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
@@ -65,7 +63,7 @@ export default function IndexLayout() {
sideOffset={10} sideOffset={10}
> >
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key='display-option-1' key="display-option-1"
value={settings.libraryOptions.display === "row"} value={settings.libraryOptions.display === "row"}
onValueChange={() => onValueChange={() =>
updateSettings({ updateSettings({
@@ -77,12 +75,12 @@ export default function IndexLayout() {
} }
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-1'> <DropdownMenu.ItemTitle key="display-title-1">
{t("library.options.row")} {t("library.options.row")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key='display-option-2' key="display-option-2"
value={settings.libraryOptions.display === "list"} value={settings.libraryOptions.display === "list"}
onValueChange={() => onValueChange={() =>
updateSettings({ updateSettings({
@@ -94,14 +92,14 @@ export default function IndexLayout() {
} }
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-2'> <DropdownMenu.ItemTitle key="display-title-2">
{t("library.options.list")} {t("library.options.list")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'> <DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.image_style")} {t("library.options.image_style")}
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.SubContent <DropdownMenu.SubContent
@@ -112,7 +110,7 @@ export default function IndexLayout() {
sideOffset={10} sideOffset={10}
> >
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key='poster-option' key="poster-option"
value={ value={
settings.libraryOptions.imageStyle === "poster" settings.libraryOptions.imageStyle === "poster"
} }
@@ -126,12 +124,12 @@ export default function IndexLayout() {
} }
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='poster-title'> <DropdownMenu.ItemTitle key="poster-title">
{t("library.options.poster")} {t("library.options.poster")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key='cover-option' key="cover-option"
value={settings.libraryOptions.imageStyle === "cover"} value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() => onValueChange={() =>
updateSettings({ updateSettings({
@@ -143,17 +141,17 @@ export default function IndexLayout() {
} }
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='cover-title'> <DropdownMenu.ItemTitle key="cover-title">
{t("library.options.cover")} {t("library.options.cover")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
</DropdownMenu.Group> </DropdownMenu.Group>
<DropdownMenu.Group key='show-titles-group'> <DropdownMenu.Group key="show-titles-group">
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"} disabled={settings.libraryOptions.imageStyle === "poster"}
key='show-titles-option' key="show-titles-option"
value={settings.libraryOptions.showTitles} value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => { onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster") if (settings.libraryOptions.imageStyle === "poster")
@@ -161,30 +159,30 @@ export default function IndexLayout() {
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,
showTitles: newValue === "on", showTitles: newValue === "on" ? true : false,
}, },
}); });
}} }}
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-titles-title'> <DropdownMenu.ItemTitle key="show-titles-title">
{t("library.options.show_titles")} {t("library.options.show_titles")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
key='show-stats-option' key="show-stats-option"
value={settings.libraryOptions.showStats} value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => { onValueChange={(newValue: string) => {
updateSettings({ updateSettings({
libraryOptions: { libraryOptions: {
...settings.libraryOptions, ...settings.libraryOptions,
showStats: newValue === "on", showStats: newValue === "on" ? true : false,
}, },
}); });
}} }}
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-stats-title'> <DropdownMenu.ItemTitle key="show-stats-title">
{t("library.options.show_stats")} {t("library.options.show_stats")}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
@@ -197,12 +195,12 @@ export default function IndexLayout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name='[libraryId]' name="[libraryId]"
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />
@@ -210,12 +208,12 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} /> <Stack.Screen key={name} name={name} options={options} />
))} ))}
<Stack.Screen <Stack.Screen
name='collections/[collectionId]' name="collections/[collectionId]"
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />

View File

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

View File

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

View File

@@ -1,36 +1,10 @@
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import {
useCallback,
useEffect,
useId,
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 ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags"; import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
@@ -38,7 +12,26 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; 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 { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
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"; type SearchType = "Library" | "Discover";
@@ -55,13 +48,8 @@ export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const searchFilterId = useId();
const orderFilterId = useId();
const { q } = params as { q: string }; const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
@@ -70,27 +58,17 @@ export default function search() {
const [debouncedSearch] = useDebounce(search, 500); const [debouncedSearch] = useDebounce(search, 500);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr(); 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(() => { const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin"; return settings?.searchEngine || "Jellyfin";
}, [settings]); }, [settings]);
useEffect(() => { useEffect(() => {
if (q && q.length > 0) { if (q && q.length > 0) setSearch(q);
setSearch(q);
}
}, [q]); }, [q]);
const searchFn = useCallback( const searchFn = useCallback(
@@ -101,94 +79,62 @@ export default function search() {
types: BaseItemKind[]; types: BaseItemKind[];
query: string; query: string;
}): Promise<BaseItemDto[]> => { }): Promise<BaseItemDto[]> => {
if (!api || !query) { if (!api || !query) return [];
return [];
}
try { try {
if (searchEngine === "Jellyfin") { if (searchEngine === "Jellyfin") {
const searchApi = await getItemsApi(api).getItems({ const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query, searchTerm: query,
limit: 10, limit: 10,
includeItemTypes: types, 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) { } catch (error) {
console.error("Error during search:", error); console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an 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(); const navigation = useNavigation();
useLayoutEffect(() => { useLayoutEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerSearchBarOptions: { headerSearchBarOptions: {
ref: searchBarRef,
placeholder: t("search.search"), placeholder: t("search.search"),
onChangeText: (e: any) => { onChangeText: (e: any) => {
router.setParams({ q: "" }); router.setParams({ q: "" });
setSearch(e.nativeEvent.text); setSearch(e.nativeEvent.text);
}, },
hideWhenScrolling: false, hideWhenScrolling: false,
autoFocus: false, autoFocus: true,
}, },
}); });
}, [navigation]); }, [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({ const { data: movies, isFetching: l1 } = useQuery({
queryKey: ["search", "movies", debouncedSearch], queryKey: ["search", "movies", debouncedSearch],
queryFn: () => queryFn: () =>
@@ -254,223 +200,160 @@ export default function search() {
}, [l1, l2, l3, l7, l8]); }, [l1, l2, l3, l7, l8]);
return ( return (
<ScrollView <>
keyboardDismissMode='on-drag' <ScrollView
contentInsetAdjustmentBehavior='automatic' keyboardDismissMode="on-drag"
contentContainerStyle={{ contentInsetAdjustmentBehavior="automatic"
paddingLeft: insets.left, contentContainerStyle={{
paddingRight: insets.right, paddingLeft: insets.left,
}} paddingRight: insets.right,
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}} }}
> >
{jellyseerrApi && ( <View className="flex flex-col">
<ScrollView {jellyseerrApi && (
horizontal <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
className='flex flex-row flex-wrap space-x-2 px-4 mb-2' <TouchableOpacity onPress={() => setSearchType("Library")}>
> <Tag
<TouchableOpacity onPress={() => setSearchType("Library")}> text={t("search.library")}
<Tag textClass="p-1"
text={t("search.library")} className={
textClass='p-1' searchType === "Library" ? "bg-purple-600" : undefined
className={ }
searchType === "Library" ? "bg-purple-600" : undefined />
} </TouchableOpacity>
/> <TouchableOpacity onPress={() => setSearchType("Discover")}>
</TouchableOpacity> <Tag
<TouchableOpacity onPress={() => setSearchType("Discover")}> text={t("search.discover")}
<Tag textClass="p-1"
text={t("search.discover")} className={
textClass='p-1' searchType === "Discover" ? "bg-purple-600" : undefined
className={ }
searchType === "Discover" ? "bg-purple-600" : undefined />
} </TouchableOpacity>
/> </View>
</TouchableOpacity> )}
{searchType === "Discover" &&
!loading &&
noResults &&
debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
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={orderFilterId}
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 className='mt-2'> <View className="mt-2">
<LoadingSkeleton isLoading={loading} /> <LoadingSkeleton isLoading={loading} />
</View>
{searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.movies")}
items={movies}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className='flex flex-col w-28 mr-2'
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={series}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={episodes}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-44 mr-2'
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={collections}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={actors}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View> </View>
) : (
<JellyserrIndexPage
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
)}
{searchType === "Library" && {searchType === "Library" ? (
(!loading && noResults && debouncedSearch.length > 0 ? ( <View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<View> <SearchItemWrapper
<Text className='text-center text-lg font-bold mt-4'> header={t("search.movies")}
{t("search.no_results_found_for")} ids={movies?.map((m) => m.Id!)}
</Text> renderItem={(item: BaseItemDto) => (
<Text className='text-xs text-purple-600 text-center'> <TouchableItemRouter
"{debouncedSearch}" key={item.Id}
</Text> className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
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"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
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"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
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"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
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"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View> </View>
) : debouncedSearch.length === 0 ? ( ) : (
<View className='mt-4 flex flex-col items-center space-y-2'> <JellyserrIndexPage searchQuery={debouncedSearch} />
{exampleSearches.map((e) => ( )}
<TouchableOpacity
onPress={() => { {searchType === "Library" && (
setSearch(e); <>
searchBarRef.current?.setText(e); {!loading && noResults && debouncedSearch.length > 0 ? (
}} <View>
key={e} <Text className="text-center text-lg font-bold mt-4">
className='mb-2' {t("search.no_results_found_for")}
> </Text>
<Text className='text-purple-600'>{e}</Text> <Text className="text-xs text-purple-600 text-center">
</TouchableOpacity> "{debouncedSearch}"
))} </Text>
</View> </View>
) : null)} ) : debouncedSearch.length === 0 ? (
</View> <View className="mt-4 flex flex-col items-center space-y-2">
</ScrollView> {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,26 +1,29 @@
import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { import {
createNativeBottomTabNavigator, createNativeBottomTabNavigator,
type NativeBottomTabNavigationEventMap, NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
const { Navigator } = createNativeBottomTabNavigator();
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationOptions, BottomTabNavigationOptions,
typeof Navigator, typeof Navigator,
TabNavigationState<ParamListBase>, TabNavigationState<ParamListBase>,
NativeBottomTabNavigationEventMap NativeBottomTabNavigationEventMap
@@ -43,33 +46,28 @@ export default function TabLayout() {
clearTimeout(timer); clearTimeout(timer);
}; };
} }
}, []), }, [])
); );
return ( return (
<> <>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style="light" />
<NativeTabs <NativeTabs
sidebarAdaptable={false} sidebarAdaptable={false}
tabBarStyle={{ ignoresTopSafeArea
backgroundColor: "#121212", barTintColor={Platform.OS === "android" ? "#121212" : undefined}
}}
tabBarActiveTintColor={Colors.primary} tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance='default' scrollEdgeAppearance="default"
> >
<NativeTabs.Screen redirect name='index' /> <NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen <NativeTabs.Screen
listeners={(_e) => ({ name="(home)"
tabPress: (_e) => {
eventBus.emit("scrollToTop");
},
})}
name='(home)'
options={{ options={{
title: t("tabs.home"), title: t("tabs.home"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? (_e) => require("@/assets/icons/house.fill.png") ? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "house.fill" } ? { sfSymbol: "house.fill" }
@@ -77,17 +75,13 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
listeners={(_e) => ({ name="(search)"
tabPress: (_e) => {
eventBus.emit("searchTabPressed");
},
})}
name='(search)'
options={{ options={{
title: t("tabs.search"), title: t("tabs.search"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? (_e) => require("@/assets/icons/magnifyingglass.png") ? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "magnifyingglass" } ? { sfSymbol: "magnifyingglass" }
@@ -95,12 +89,12 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name='(favorites)' name="(favorites)"
options={{ options={{
title: t("tabs.favorites"), title: t("tabs.favorites"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? ({ focused }) => ? ({ color, focused, size }) =>
focused focused
? require("@/assets/icons/heart.fill.png") ? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png") : require("@/assets/icons/heart.png")
@@ -111,12 +105,13 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name='(libraries)' name="(libraries)"
options={{ options={{
title: t("tabs.library"), title: t("tabs.library"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? (_e) => require("@/assets/icons/server.rack.png") ? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "rectangle.stack.fill" } ? { sfSymbol: "rectangle.stack.fill" }
@@ -124,13 +119,14 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name='(custom-links)' name="(custom-links)"
options={{ options={{
title: t("tabs.custom_links"), title: t("tabs.custom_links"),
tabBarItemHidden: !settings?.showCustomMenuLinks, // @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? (_e) => require("@/assets/icons/list.png") ? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "list.dash.fill" } ? { sfSymbol: "list.dash.fill" }

View File

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

View File

@@ -1,50 +1,50 @@
import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { useHaptic } from "@/hooks/useHaptic"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules"; import { VlcPlayerView } from "@/modules/vlc-player";
import type { import {
PipStartedPayload, PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider"; // import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types"; const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import native from "@/utils/profiles/native";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas"; getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
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";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() { export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -53,13 +53,8 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => { const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
// Load persisted state from storage
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
return saved ?? false;
});
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false); const [isPipStarted, setIsPipStarted] = useState(false);
@@ -67,11 +62,10 @@ export default function page() {
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
const VolumeManager = Platform.isTV let getDownloadedItem = null;
? null if (!Platform.isTV) {
: require("react-native-volume-manager"); getDownloadedItem = downloadProvider.useDownload();
}
const downloadUtils = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -82,11 +76,6 @@ export default function page() {
lightHapticFeedback(); lightHapticFeedback();
}, []); }, []);
// Persist ignoreSafeAreas state whenever it changes
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
const { const {
itemId, itemId,
audioIndex: audioIndexStr, audioIndex: audioIndexStr,
@@ -94,7 +83,6 @@ export default function page() {
mediaSourceId, mediaSourceId,
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl,
} = useGlobalSearchParams<{ } = useGlobalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -102,433 +90,270 @@ export default function page() {
mediaSourceId: string; mediaSourceId: string;
bitrateValue: string; bitrateValue: string;
offline: string; offline: string;
/** Playback position in ticks. */
playbackPosition?: string;
}>(); }>();
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
const audioIndex = audioIndexStr const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
? Number.parseInt(audioIndexStr, 10) const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
: undefined;
const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10)
: -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr
? Number.parseInt(bitrateValueStr, 10) ? parseInt(bitrateValueStr, 10)
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const {
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>( data: item,
null, isLoading: isLoadingItem,
); isError: isErrorItem,
const [itemStatus, setItemStatus] = useState({ } = useQuery({
isLoading: true, queryKey: ["item", itemId],
isError: false, queryFn: async () => {
}); if (offline && !Platform.isTV) {
const item = await getDownloadedItem.getDownloadedItem(itemId);
/** Gets the initial playback position from the URL. */ if (item) return item.item;
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl]);
useEffect(() => {
const fetchItemData = async () => {
setItemStatus({ isLoading: true, isError: false });
try {
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = downloadUtils.getDownloadedItemById(itemId);
if (data) {
fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data);
}
} 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 });
} }
};
if (itemId) { const res = await getUserLibraryApi(api!).getItem({
fetchItemData(); itemId,
} userId: user?.Id,
}, [itemId, offline, api, user?.Id]); });
interface Stream { return res.data;
mediaSource: MediaSourceInfo; },
sessionId: string; enabled: !!itemId,
url: string; staleTime: 0,
}
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
}); });
const [stream, setStream] = useState<{
mediaSource: MediaSourceInfo;
url: string;
sessionId: string | undefined;
} | null>(null);
const [isLoadingStream, setIsLoadingStream] = useState(true);
const [isErrorStream, setIsErrorStream] = useState(false);
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStream = async () => {
setStreamStatus({ isLoading: true, isError: false }); setIsLoadingStream(true);
setIsErrorStream(false);
try { try {
let result: Stream | null = null; if (offline && !Platform.isTV) {
if (offline && downloadedItem && downloadedItem.mediaSource) { const data = await getDownloadedItem.getDownloadedItem(itemId);
const url = downloadedItem.videoFilePath; if (!data?.mediaSource) {
if (item) { setStream(null);
result = { return;
mediaSource: downloadedItem.mediaSource, }
sessionId: "",
url: url, const url = await getDownloadedFileUrl(data.item.Id!);
};
} if (item) {
} else { setStream({
const native = generateDeviceProfile(); mediaSource: data.mediaSource as MediaSourceInfo,
const transcoding = generateDeviceProfile({ transcode: true }); url,
const res = await getStreamUrl({ sessionId: undefined,
api, });
item,
startTimeTicks: getInitialPlaybackTicks(),
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : 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"),
);
return; 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) { } catch (error) {
console.error("Failed to fetch stream:", error); console.error("Error fetching stream:", error);
setStreamStatus({ isLoading: false, isError: true }); setIsErrorStream(true);
setStream(null);
} finally {
setIsLoadingStream(false);
} }
}; };
fetchStreamData();
}, [
itemId,
mediaSourceId,
bitrateValue,
api,
item,
user?.Id,
downloadedItem,
]);
useEffect(() => { fetchStream();
if (!stream || !api) return; }, [itemId, mediaSourceId]);
const reportPlaybackStart = async () => {
await getPlaystateApi(api).reportPlaybackStart({ const togglePlay = useCallback(async () => {
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, if (!api) return;
});
};
reportPlaybackStart();
}, [stream, api]);
const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
playbackManager.reportPlaybackProgress(
item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
} else { } else {
videoRef.current?.play(); 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 () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!, playSessionId: stream?.sessionId!,
}); });
}, [
api, revalidateProgressCache();
item, }, [api, item, mediaSourceId, stream]);
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => { const stop = useCallback(() => {
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.stop(); videoRef.current?.stop();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped]); }, [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( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
if (isBuffering) { if (isBuffering) {
setIsBuffering(false); setIsBuffering(false);
} }
progress.set(currentTime); progress.set(currentTime);
// Update the playback position in the URL. if (offline) return;
router.setParams({
playbackPosition: msToTicks(currentTime).toString(), const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
}); });
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}, },
[ [item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
); );
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
/** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
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({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
togglePlay: togglePlay, togglePlay: togglePlay,
stopPlayback: stop, stopPlayback: stop,
offline, offline,
toggleMute: toggleMuteCb,
volumeUp: volumeUpCb,
volumeDown: volumeDownCb,
setVolume: setVolumeCb,
}); });
const onPlaybackStateChanged = useCallback( const onPipStarted = useCallback((e: PipStartedPayload) => {
async (e: PlaybackStatePayload) => { const { pipStarted } = e.nativeEvent;
const { state, isBuffering, isPlaying } = e.nativeEvent; setIsPipStarted(pipStarted);
if (state === "Playing") { }, []);
setIsPlaying(true);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") { const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
setIsPlaying(false); const { state, isBuffering, isPlaying } = e.nativeEvent;
if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (isPlaying) { if (state === "Playing") {
setIsPlaying(true); setIsPlaying(true);
setIsBuffering(false); return;
} else if (isBuffering) { }
setIsBuffering(true);
} if (state === "Paused") {
}, setIsPlaying(false);
[playbackManager, item?.Id, progress], 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 = const allAudio =
stream?.mediaSource.MediaStreams?.filter( 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 = const allSubs =
stream?.mediaSource.MediaStreams?.filter( stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle", (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: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
}));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find( const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex, (sub) => sub.Index === subtitleIndex
); );
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if ( if (
chosenSubtitleTrack && chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) { ) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack) ? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack); : textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`); initOptions.push(`--sub-track=${finalIndex}`);
} }
@@ -536,40 +361,35 @@ export default function page() {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} }
const [isMounted, setIsMounted] = useState(false); const insets = useSafeAreaInsets();
// Add useEffect to handle mounting
useEffect(() => { useEffect(() => {
setIsMounted(true); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => setIsMounted(false); return () => {
}, []); beforeRemoveListener();
};
}, [navigation]);
// Show error UI first, before checking loading/missingdata if (!item || isLoadingItem || !stream)
if (itemStatus.isError || streamStatus.isError) {
return ( 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">
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
}
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader /> <Loader />
</View> </View>
); );
}
if (itemStatus.isError || streamStatus.isError) if (isErrorItem || isErrorStream)
return ( 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">
<Text className='text-white'>{t("player.error")}</Text> <Text className="text-white">{t("player.error")}</Text>
</View> </View>
); );
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
<View <View
@@ -589,7 +409,7 @@ export default function page() {
source={{ source={{
uri: stream?.url || "", uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: !offline, isNetwork: true,
startPosition, startPosition,
externalSubtitles, externalSubtitles,
initOptions, initOptions,
@@ -599,6 +419,7 @@ export default function page() {
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted} onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
}} }}
@@ -606,13 +427,13 @@ export default function page() {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), 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); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
</View> </View>
{!isPipStarted && isMounted === true && item && ( {videoRef.current && !isPipStarted && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -628,7 +449,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef.current?.startPictureInPicture} startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play} play={videoRef.current?.play}
pause={videoRef.current?.pause} pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo} seek={videoRef.current?.seekTo}
@@ -636,9 +457,10 @@ export default function page() {
getAudioTracks={videoRef.current?.getAudioTracks} getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks} getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline} offline={offline}
setSubtitleTrack={videoRef.current?.setSubtitleTrack} setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current?.setSubtitleURL} setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current?.setAudioTrack} setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc isVlc
/> />
)} )}

View File

@@ -7,13 +7,13 @@ import { type PropsWithChildren } from "react";
*/ */
export default function Root({ children }: PropsWithChildren) { export default function Root({ children }: PropsWithChildren) {
return ( return (
<html lang='en'> <html lang="en">
<head> <head>
<meta charSet='utf-8' /> <meta charSet="utf-8" />
<meta httpEquiv='X-UA-Compatible' content='IE=edge' /> <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta <meta
name='viewport' name="viewport"
content='width=device-width, initial-scale=1, shrink-to-fit=no' 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!" }} /> <Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}> <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}> <Link href={"/home"} style={styles.link}>
<ThemedText type='link'>Go to home screen!</ThemedText> <ThemedText type="link">Go to home screen!</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </>

View File

@@ -1,72 +1,52 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStorage,
JellyfinProvider, JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { Settings, useSettings } from "@/utils/atoms/settings";
import { import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
BACKGROUND_FETCH_TASK, import { LogProvider, writeToLog } from "@/utils/log";
BACKGROUND_FETCH_TASK_SESSIONS,
registerBackgroundFetchAsyncSessions,
} from "@/utils/background-tasks";
import {
LogProvider,
writeDebugLog,
writeErrorLog,
writeToLog,
} from "@/utils/log";
import { storage } from "@/utils/mmkv"; 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
const BackGroundDownloader = !Platform.isTV const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader") ? require("@kesha-antonov/react-native-background-downloader")
: null; : null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const BackgroundFetch = !Platform.isTV const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch") ? require("expo-background-fetch")
: null; : null;
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router";
import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider, useTranslation } from "react-i18next";
import { Appearance, AppState } from "react-native"; import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated"; import "react-native-reanimated";
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 { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
@@ -78,19 +58,10 @@ if (!Platform.isTV) {
}); });
} }
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional.
SplashScreen.setOptions({
duration: 500,
fade: true,
});
function useNotificationObserver() { function useNotificationObserver() {
useEffect(() => { if (Platform.isTV) return;
if (Platform.isTV) return;
useEffect(() => {
let isMounted = true; let isMounted = true;
function redirect(notification: typeof Notifications.Notification) { function redirect(notification: typeof Notifications.Notification) {
@@ -106,13 +77,13 @@ function useNotificationObserver() {
return; return;
} }
redirect(response?.notification); redirect(response?.notification);
}, }
); );
const subscription = Notifications.addNotificationResponseReceivedListener( const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => { (response: { notification: any }) => {
redirect(response.notification); redirect(response.notification);
}, }
); );
return () => { return () => {
@@ -123,32 +94,19 @@ function useNotificationObserver() {
} }
if (!Platform.isTV) { if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
console.log("TaskManager ~ sessions trigger");
const api = store.get(apiAtom);
if (api === null || api === undefined) return;
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: 360,
});
const result = response.data.filter((s) => s.NowPlayingItem);
Notifications.setBadgeCountAsync(result.length);
return BackgroundFetch.BackgroundFetchResult.NewData;
});
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings"); const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const settings: Partial<Settings> = JSON.parse(settingsData); const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload) if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage(); const token = getTokenFromStorage();
@@ -158,6 +116,74 @@ if (!Platform.isTV) {
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type! // Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData; return BackgroundFetch.BackgroundFetchResult.NewData;
}); });
@@ -166,7 +192,7 @@ if (!Platform.isTV) {
const checkAndRequestPermissions = async () => { const checkAndRequestPermissions = async () => {
try { try {
const hasAskedBefore = storage.getString( const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission", "hasAskedForNotificationPermission"
); );
if (hasAskedBefore !== "true") { if (hasAskedBefore !== "true") {
@@ -188,7 +214,7 @@ const checkAndRequestPermissions = async () => {
writeToLog( writeToLog(
"ERROR", "ERROR",
"Error checking/requesting notification permissions:", "Error checking/requesting notification permissions:",
error, error
); );
console.error("Error checking/requesting notification permissions:", error); console.error("Error checking/requesting notification permissions:", error);
} }
@@ -198,15 +224,17 @@ export default function RootLayout() {
Appearance.setColorScheme("dark"); Appearance.setColorScheme("dark");
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <SplashScreenProvider>
<JotaiProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<ActionSheetProvider> <JotaiProvider>
<I18nextProvider i18n={i18n}> <ActionSheetProvider>
<Layout /> <I18nextProvider i18n={i18n}>
</I18nextProvider> <Layout />
</ActionSheetProvider> </I18nextProvider>
</JotaiProvider> </ActionSheetProvider>
</GestureHandlerRootView> </JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
); );
} }
@@ -224,233 +252,148 @@ const queryClient = new QueryClient({
function Layout() { function Layout() {
const [settings] = useSettings(); const [settings] = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
const segments = useSegments();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en", settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
); );
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
useNotificationObserver(); if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>(); const { i18n } = useTranslation();
const notificationListener = useRef<EventSubscription>();
const responseListener = useRef<EventSubscription>();
useEffect(() => { useEffect(() => {
if (!Platform.isTV && expoPushToken && api && user) { checkAndRequestPermissions();
api }, []);
?.post("/Streamyfin/device", {
token: expoPushToken.data, useEffect(() => {
deviceId: getOrSetDeviceId(), // If the user has auto rotate enabled, unlock the orientation
userId: user.Id, if (settings.autoRotate === true) {
}) ScreenOrientation.unlockAsync();
.then((_) => console.log("Posted expo push token")) } else {
.catch((_) => // If the user has auto rotate disabled, lock the orientation to portrait
writeErrorLog("Failed to push expo push token to plugin"), ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
); );
} else console.log("No token available"); }
}, [api, expoPushToken, user]); }, [settings]);
async function registerNotifications() { useEffect(() => {
if (Platform.OS === "android") { const subscription = AppState.addEventListener(
console.log("Setting android notification channel 'default'"); "change",
await Notifications?.setNotificationChannelAsync("default", { (nextAppState) => {
name: "default", if (
}); appState.current.match(/inactive|background/) &&
} nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
}
);
await checkAndRequestPermissions(); BackGroundDownloader.checkForExistingDownloads();
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(() => {
if (!Platform.isTV) {
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 () => { return () => {
notificationListener.current && subscription.remove();
Notifications?.removeNotificationSubscription(
notificationListener.current,
);
responseListener.current &&
Notifications?.removeNotificationSubscription(
responseListener.current,
);
}; };
} }, []);
}, [user, api]); }
useEffect(() => { const [loaded] = useFonts({
if (Platform.isTV) { SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
return; });
}
if (segments.includes("direct-player" as never)) { useSplashScreenLoading(!loaded);
if (
!settings.followDeviceOrientation &&
settings.defaultVideoOrientation
) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return;
}
if (settings.followDeviceOrientation === true) { if (!loaded) {
ScreenOrientation.unlockAsync(); return null;
} else { }
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
useEffect(() => {
if (Platform.isTV) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
});
BackGroundDownloader.checkForExistingDownloads();
return () => {
subscription.remove();
};
}, []);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JellyfinProvider> <JobQueueProvider>
<PlaySettingsProvider> <JellyfinProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<BottomSheetModalProvider> <DownloadProvider>
<SystemBars style='light' hidden={false} /> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <SystemBars style="light" hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'> <ThemeProvider value={DarkTheme}>
<Stack.Screen <Stack>
name='(auth)/(tabs)' <Stack.Screen
options={{ name="(auth)/(tabs)"
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen </ThemeProvider>
name='(auth)/player' </BottomSheetModalProvider>
options={{ </DownloadProvider>
headerShown: false, </WebSocketProvider>
title: "", </LogProvider>
header: () => null, </PlaySettingsProvider>
}} </JellyfinProvider>
/> </JobQueueProvider>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
storage.set("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -1,21 +1,3 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
SafeAreaView,
TouchableOpacity,
View,
} from "react-native";
import { z } from "zod";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -23,17 +5,31 @@ import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
SafeAreaView,
TouchableOpacity,
View,
} from "react-native";
import { z } from "zod";
import { t } from 'i18next';
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),});
});
const Login: React.FC = () => { const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -41,8 +37,6 @@ const Login: React.FC = () => {
password: _password, password: _password,
} = params as { apiUrl: string; username: string; password: string }; } = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl); const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>(""); const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
@@ -53,13 +47,12 @@ const Login: React.FC = () => {
password: _password, password: _password,
}); });
/**
* A way to auto login based on a link
*/
useEffect(() => { useEffect(() => {
(async () => { (async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) { if (_apiUrl) {
await setServer({ setServer({
address: _apiUrl, address: _apiUrl,
}); });
@@ -73,6 +66,7 @@ const Login: React.FC = () => {
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerTitle: serverName, headerTitle: serverName,
@@ -82,20 +76,18 @@ const Login: React.FC = () => {
onPress={() => { onPress={() => {
removeServer(); removeServer();
}} }}
className='flex flex-row items-center' className="flex flex-row items-center"
> >
<Ionicons name='chevron-back' size={18} color={Colors.primary} /> <Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className='ml-2 text-purple-600'> <Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
{t("login.change_server")}
</Text>
</TouchableOpacity> </TouchableOpacity>
) : null, ) : null,
}); });
}, [serverName, navigation, api?.basePath]); }, [serverName, navigation, api?.basePath]);
const handleLogin = async () => { const [loading, setLoading] = useState<boolean>(false);
Keyboard.dismiss();
const handleLogin = async () => {
setLoading(true); setLoading(true);
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
@@ -106,16 +98,15 @@ const Login: React.FC = () => {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
Alert.alert( Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/** /**
* Checks the availability and validity of a Jellyfin server URL. * Checks the availability and validity of a Jellyfin server URL.
* *
@@ -177,33 +168,26 @@ const Login: React.FC = () => {
if (result === undefined) { if (result === undefined) {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.could_not_connect_to_server"), t("login.could_not_connect_to_server")
); );
return; return;
} }
await setServer({ address: url }); setServer({ address: url });
}, []); }, []);
const handleQuickConnect = async () => { const handleQuickConnect = async () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert( Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
t("login.quick_connect"), {
t("login.enter_code_to_login", { code: code }), text: t("login.got_it"),
[ },
{ ]);
text: t("login.got_it"),
},
],
);
} }
} catch (_error) { } catch (error) {
Alert.alert( Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
} }
}; };
@@ -213,127 +197,131 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : "height"}
> >
{api?.basePath ? ( {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 h-full relative items-center justify-center">
<View className='flex flex-col space-y-2'> <View className="px-4 -mt-20 w-full">
<Text className='text-2xl font-bold -mb-2'> <View className="flex flex-col space-y-2">
{serverName ? ( <Text className="text-2xl font-bold -mb-2">
<> <>
{`${t("login.login_to_title")} `} {serverName ? (
<Text className='text-purple-600'>{serverName}</Text> <>
</> {t("login.login_to_title") + " "}
) : ( <Text className="text-purple-600">{serverName}</Text>
t("login.login_title") </>
)} ) : t("login.login_title")}
</>
</Text> </Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text> <Text className="text-xs text-neutral-400">
<Input {api.basePath}
placeholder={t("login.username_placeholder")} </Text>
onChangeText={(text) => <Input
setCredentials({ ...credentials, username: text }) placeholder={t("login.username_placeholder")}
} onChangeText={(text) =>
value={credentials.username} setCredentials({ ...credentials, username: text })
keyboardType='default' }
returnKeyType='done' value={credentials.username}
autoCapitalize='none' autoFocus
// Changed from username to oneTimeCode because it is a known issue in RN secureTextEntry={false}
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037 keyboardType="default"
textContentType='oneTimeCode' returnKeyType="done"
clearButtonMode='while-editing' autoCapitalize="none"
maxLength={500} textContentType="username"
/> clearButtonMode="while-editing"
maxLength={500}
/>
<Input <Input
placeholder={t("login.password_placeholder")} placeholder={t("login.password_placeholder")}
onChangeText={(text) => onChangeText={(text) =>
setCredentials({ ...credentials, password: text }) setCredentials({ ...credentials, password: text })
} }
value={credentials.password} value={credentials.password}
secureTextEntry secureTextEntry
keyboardType='default' keyboardType="default"
returnKeyType='done' returnKeyType="done"
autoCapitalize='none' autoCapitalize="none"
textContentType='password' textContentType="password"
clearButtonMode='while-editing' clearButtonMode="while-editing"
maxLength={500} maxLength={500}
/> />
<View className='flex flex-row items-center justify-between'> <View className="flex flex-row items-center justify-between">
<Button <Button
onPress={handleLogin} onPress={handleLogin}
loading={loading} loading={loading}
className='flex-1 mr-2' className="flex-1 mr-2"
> >
{t("login.login_button")} {t("login.login_button")}
</Button> </Button>
<TouchableOpacity <TouchableOpacity
onPress={handleQuickConnect} 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 <MaterialCommunityIcons
name='cellphone-lock' name="cellphone-lock"
size={24} size={24}
color='white' color="white"
/> />
</TouchableOpacity> </TouchableOpacity>
</View>
</View> </View>
</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'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<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'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
/>
</View> </View>
</View> </>
) : (
<>
<View className="flex flex-col h-full items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label="Server URL"
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={(server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
}}
/>
</View>
</View>
</>
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

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

View File

@@ -1,33 +1,22 @@
import { MMKV } from "react-native-mmkv"; import {MMKV} from "react-native-mmkv";
declare module "react-native-mmkv" { declare module "react-native-mmkv" {
interface MMKV { interface MMKV {
get<T>(key: string): T | undefined; get<T>(key: string): T | undefined
setAny(key: string, value: any | undefined): void; setAny(key: string, value: any | undefined): void
} }
} }
// Add the augmentation methods directly to the MMKV prototype MMKV.prototype.get = function <T> (key: string): T | undefined {
// This follows the recommended pattern while adding the helper methods your app uses const serializedItem = this.getString(key);
MMKV.prototype.get = function <T>(key: string): T | undefined { return serializedItem ? JSON.parse(serializedItem) : undefined;
try { }
const serializedItem = this.getString(key);
if (!serializedItem) return undefined;
return JSON.parse(serializedItem);
} catch (error) {
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
return undefined;
}
};
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
try { if (value === undefined) {
if (value === undefined) { this.delete(key)
this.delete(key);
} else {
this.set(key, JSON.stringify(value));
}
} catch (error) {
console.warn(`Failed to set MMKV value for key "${key}":`, error);
} }
}; 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(); const bytes = this.valueOf();
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; 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)); 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 () { Number.prototype.secondsToMilliseconds = function () {

View File

@@ -5,10 +5,12 @@ declare global {
} }
String.prototype.toTitle = function () { String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace( return this
/\w\S*/g, .replaceAll("_", " ")
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(), .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); api.cache(true);
return { return {
presets: ["babel-preset-expo"], presets: ["babel-preset-expo"],

View File

@@ -1,61 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
"files": {
"includes": [
"**/*",
"!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"
}
}
}

1703
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,113 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import type { FC } from "react"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { View, type ViewProps } from "react-native"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { RoundButton } from "@/components/RoundButton"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useFavorite } from "@/hooks/useFavorite"; import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacityProps, View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
type: "item" | "series";
} }
export const AddToFavorites: FC<Props> = ({ item, ...props }) => { export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item); 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 ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
size='large' size="large"
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined} fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite} onPress={() => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/> />
</View> </View>
); );

View File

@@ -1,11 +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 { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -19,37 +17,34 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
const isTv = Platform.isTV; if (Platform.isTV) return null;
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source], [source]
); );
const selectedAudioSteam = useMemo( const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected), () => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected], [audioStreams, selected]
); );
const { t } = useTranslation(); const { t } = useTranslation();
if (isTv) return null;
return ( return (
<View <View
className='flex shrink' className="flex shrink"
style={{ style={{
minWidth: 50, minWidth: 50,
}} }}
> >
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className='flex flex-col' {...props}> <View className="flex flex-col" {...props}>
<Text className='opacity-50 mb-1 text-xs'> <Text className="opacity-50 mb-1 text-xs">
{t("item_card.audio")} {t("item_card.audio")}
</Text> </Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className='' numberOfLines={1}> <Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle} {selectedAudioSteam?.DisplayTitle}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -57,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={true} loop={true}
side='bottom' side="bottom"
align='start' align="start"
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={8} 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"; import { Text } from "./common/Text";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
${variant === "gray" && "bg-neutral-800"} ${variant === "gray" && "bg-neutral-800"}
`} `}
> >
{iconLeft && <View className='mr-1'>{iconLeft}</View>} {iconLeft && <View className="mr-1">{iconLeft}</View>}
<Text <Text
className={` className={`
text-xs text-xs

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { Platform } from "react-native"; import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -11,6 +11,12 @@ import GoogleCast, {
} from "react-native-google-cast"; } from "react-native-google-cast";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
}
export function Chromecast({ export function Chromecast({
width = 48, width = 48,
height = 48, height = 48,
@@ -38,15 +44,19 @@ export function Chromecast({
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null, Platform.OS === "android" ? (
[Platform.OS], <CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
); );
if (background === "transparent") if (background === "transparent")
return ( return (
<RoundButton <RoundButton
size='large' size="large"
className='mr-2' className="mr-2"
background={false} background={false}
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -55,13 +65,13 @@ export function Chromecast({
{...props} {...props}
> >
<AndroidCastButton /> <AndroidCastButton />
<Feather name='cast' size={22} color={"white"} /> <Feather name="cast" size={22} color={"white"} />
</RoundButton> </RoundButton>
); );
return ( return (
<RoundButton <RoundButton
size='large' size="large"
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog(); else CastContext.showCastDialog();
@@ -69,7 +79,7 @@ export function Chromecast({
{...props} {...props}
> >
<AndroidCastButton /> <AndroidCastButton />
<Feather name='cast' size={22} color={"white"} /> <Feather name="cast" size={22} color={"white"} />
</RoundButton> </RoundButton>
); );
} }

View File

@@ -0,0 +1 @@
export * from "zeego/context-menu";

View File

@@ -1,13 +1,12 @@
import { Ionicons } from "@expo/vector-icons"; import { apiAtom } from "@/providers/JellyfinProvider";
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 { Image } from "expo-image";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
item: BaseItemDto; item: BaseItemDto;
@@ -28,43 +27,52 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
* Get horizontal poster for movie and episode, with failover to primary. * Get horizontal poster for movie and episode, with failover to primary.
*/ */
const url = useMemo(() => { const url = useMemo(() => {
if (!api) { if (!api) return;
return;
}
if (item.Type === "Episode" && useEpisodePoster) { if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
} }
if (item.Type === "Episode") { 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.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
} else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
} }
if (item.Type === "Movie") { if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) { 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/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
} else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
} }
if (item.Type === "Program") { if (item.Type === "Program") {
if (item.ImageTags?.Thumb) { 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/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`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
} }, [item]);
if (item.ImageTags?.Thumb) { const progress = useMemo(() => {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`; if (item.Type === "Program") {
const startDate = new Date(item.StartDate || "");
const endDate = new Date(item.EndDate || "");
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
} else {
return item.UserData?.PlayedPercentage || 0;
} }
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]); }, [item]);
if (!url) 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 ( return (
<View <View
@@ -73,7 +81,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"} ${size === "small" ? "w-32" : "w-44"}
`} `}
> >
<View className='w-full h-full flex items-center justify-center'> <View className="w-full h-full flex items-center justify-center">
<Image <Image
key={item.Id} key={item.Id}
id={item.Id} id={item.Id}
@@ -81,17 +89,29 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
uri: url, uri: url,
}} }}
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit='cover' contentFit="cover"
className='w-full h-full' className="w-full h-full"
/> />
{showPlayButton && ( {showPlayButton && (
<View className='absolute inset-0 flex items-center justify-center'> <View className="absolute inset-0 flex items-center justify-center">
<Ionicons name='play-circle' size={40} color='white' /> <Ionicons name="play-circle" size={40} color="white" />
</View> </View>
)} )}
</View> </View>
{!item.UserData?.Played && <WatchedIndicator item={item} />} {!progress && <WatchedIndicator item={item} />}
<ProgressBar item={item} /> {progress > 0 && (
<>
<View
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`}
></View>
</>
)}
</View> </View>
); );
}; };

View File

@@ -1,30 +1,30 @@
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 { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
type BottomSheetBackdropProps, BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router } from "expo-router"; import { Href, router, useFocusEffect } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, View, ViewProps } from "react-native";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
@@ -32,13 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next";
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -60,28 +54,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } = const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
useDownload(); const { startRemuxing } = useRemuxHlsToMp4();
const downloadedFiles = getDownloadedItems();
const [selectedOptions, setSelectedOptions] = useState< const [selectedMediaSource, setSelectedMediaSource] = useState<
SelectedOptions | undefined MediaSourceInfo | undefined | null
>(undefined); >(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const { const [selectedSubtitleStream, setSelectedSubtitleStream] =
defaultAudioIndex, useState<number>(0);
defaultBitrate, const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
defaultMediaSource, key: "Max",
defaultSubtitleIndex, value: undefined,
} = useDefaultPlaySettings(items[0], settings); });
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
[user], [user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
); );
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -90,7 +86,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
}, []); }, []);
const handleSheetChanges = useCallback((_index: number) => {}, []); const handleSheetChanges = useCallback((index: number) => {}, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
@@ -101,45 +97,20 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsNotDownloaded = useMemo( const itemsNotDownloaded = useMemo(
() => () =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)), items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles], [items, downloadedFiles]
); );
// Initialize selectedOptions with default values
useEffect(() => {
if (itemsNotDownloaded.length === 1) {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
itemsNotDownloaded.length,
]);
const itemsToDownload = useMemo(() => {
if (downloadUnwatchedOnly) {
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
}
return itemsNotDownloaded;
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
const allItemsDownloaded = useMemo(() => { const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false; if (items.length === 0) return false;
return itemsNotDownloaded.length === 0; return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]); }, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo( const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)), () => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds], [processes, itemIds]
); );
const progress = useMemo(() => { const progress = useMemo(() => {
if (itemIds.length === 1) if (itemIds.length == 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return ( return (
((itemIds.length - ((itemIds.length -
@@ -152,7 +123,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsQueued = useMemo(() => { const itemsQueued = useMemo(() => {
return ( return (
itemsNotDownloaded.length > 0 && 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]); }, [queue, itemsNotDownloaded]);
const navigateToDownloads = () => router.push("/downloads"); const navigateToDownloads = () => router.push("/downloads");
@@ -167,102 +138,117 @@ export const DownloadItems: React.FC<DownloadProps> = ({
params: { params: {
episodeSeasonIndex: firstItem.ParentIndexNumber, episodeSeasonIndex: firstItem.ParentIndexNumber,
}, },
} as Href), } as Href)
); );
}; };
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
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"));
}
}, [
queue,
setQueue,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
const initiateDownload = useCallback( const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => { async (...items: BaseItemDto[]) => {
if ( if (
!api || !api ||
!user?.Id || !user?.Id ||
items.some((p) => !p.Id) || items.some((p) => !p.Id) ||
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id) (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) { ) {
throw new Error( throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item", "DownloadItem ~ initiateDownload: No api or user or item"
); );
} }
const downloadDetailsPromises = items.map(async (item) => { let mediaSource = selectedMediaSource;
const { mediaSource, audioIndex, subtitleIndex } = let audioIndex: number | undefined = selectedAudioStream;
itemsNotDownloaded.length > 1 let subtitleIndex: number | undefined = selectedSubtitleStream;
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
subtitleIndex: selectedOptions?.subtitleIndex,
};
const downloadDetails = await getDownloadUrl({ for (const item of items) {
if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({
api, api,
item, item,
userId: user.Id!, startTimeTicks: 0,
mediaSource: mediaSource!, userId: user?.Id,
audioStreamIndex: audioIndex ?? -1, audioStreamIndex: audioIndex,
subtitleStreamIndex: subtitleIndex ?? -1, maxStreamingBitrate: maxBitrate.value,
maxBitrate: selectedOptions?.bitrate || defaultBitrate, mediaSourceId: mediaSource?.Id,
deviceId: api.deviceInfo.id, subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
}); });
return { if (!res) {
url: downloadDetails?.url,
item,
mediaSource: downloadDetails?.mediaSource,
};
});
const downloadDetails = await Promise.all(downloadDetailsPromises);
for (const { url, item, mediaSource } of downloadDetails) {
if (!url) {
Alert.alert( Alert.alert(
t("home.downloads.something_went_wrong"), 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; continue;
} }
if (!mediaSource) {
console.error(`Could not get download URL for ${item.Name}`); const { mediaSource: source, url } = res;
toast.error(
t("Could not get download URL for {{itemName}}", { if (!url || !source) throw new Error("No url");
itemName: item.Name,
}), saveDownloadItemInfoToDiskTmp(item, source, url);
);
continue; if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
} }
await startBackgroundDownload(
url,
item,
mediaSource,
selectedOptions?.bitrate || defaultBitrate,
);
} }
}, },
[ [
api, api,
user?.Id, user?.Id,
itemsNotDownloaded, itemsNotDownloaded,
selectedOptions, selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings, settings,
defaultBitrate, maxBitrate,
usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
], startRemuxing,
]
); );
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsToDownload.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsToDownload);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@@ -271,35 +257,44 @@ export const DownloadItems: React.FC<DownloadProps> = ({
appearsOnIndex={0} appearsOnIndex={0}
/> />
), ),
[], []
);
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings])
); );
const renderButtonContent = () => { const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) { if (processes && itemsProcesses.length > 0) {
return progress === 0 ? ( return progress === 0 ? (
<Loader /> <Loader />
) : ( ) : (
<View className='-rotate-45'> <View className="-rotate-45">
<ProgressCircle <ProgressCircle
size={24} size={24}
fill={progress} fill={progress}
width={4} width={4}
tintColor='#9334E9' tintColor="#9334E9"
backgroundColor='#bdc3c7' backgroundColor="#bdc3c7"
/> />
</View> </View>
); );
} } else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
if (itemsQueued) { } else if (allItemsDownloaded) {
return <Ionicons name='hourglass' size={24} color='white' />;
}
if (allItemsDownloaded) {
return <DownloadedIconComponent />; return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
} }
return <MissingDownloadIconComponent />;
}; };
const onButtonPress = () => { const onButtonPress = () => {
@@ -332,93 +327,59 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View> <View>
<Text className='font-bold text-2xl text-neutral-100'> <Text className="font-bold text-2xl text-neutral-100">
{title} {title}
</Text> </Text>
<Text className='text-neutral-300'> <Text className="text-neutral-300">
{subtitle || {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
t("item_card.download.download_x_item", {
item_count: itemsToDownload.length,
})}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 w-full items-start'> <View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector <BitrateSelector
inverted inverted
onChange={(val) => onChange={setMaxBitrate}
setSelectedOptions( selected={maxBitrate}
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/> />
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
<Switch
onValueChange={setDownloadUnwatchedOnly}
value={downloadUnwatchedOnly}
/>
</View>
)}
{itemsNotDownloaded.length === 1 && ( {itemsNotDownloaded.length === 1 && (
<View> <>
<MediaSourceSelector <MediaSourceSelector
item={items[0]} item={items[0]}
onChange={(val) => onChange={setSelectedMediaSource}
setSelectedOptions( selected={selectedMediaSource}
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/> />
{selectedOptions?.mediaSource && ( {selectedMediaSource && (
<View className='flex flex-col space-y-2'> <View className="flex flex-col space-y-2">
<AudioTrackSelector <AudioTrackSelector
source={selectedOptions.mediaSource} source={selectedMediaSource}
onChange={(val) => { onChange={setSelectedAudioStream}
setSelectedOptions( selected={selectedAudioStream}
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
source={selectedOptions.mediaSource} source={selectedMediaSource}
onChange={(val) => { onChange={setSelectedSubtitleStream}
setSelectedOptions( selected={selectedSubtitleStream}
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
);
}}
selected={selectedOptions.subtitleIndex}
/> />
</View> </View>
)} )}
</View> </>
)} )}
</View> </View>
<Button <Button
className='mt-auto' className="mt-auto"
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
color='purple' color="purple"
> >
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
<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")}
</Text>
</View>
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>
@@ -430,23 +391,19 @@ export const DownloadSingleItem: React.FC<{
size?: "default" | "large"; size?: "default" | "large";
item: BaseItemDto; item: BaseItemDto;
}> = ({ item, size = "default" }) => { }> = ({ item, size = "default" }) => {
if (Platform.isTV) return;
return ( return (
<DownloadItems <DownloadItems
size={size} size={size}
title={ title={item.Type == "Episode"
item.Type === "Episode" ? t("item_card.download.download_episode")
? t("item_card.download.download_episode") : t("item_card.download.download_movie")}
: t("item_card.download.download_movie")
}
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (
<Ionicons name='cloud-download-outline' size={24} color='white' /> <Ionicons name="cloud-download-outline" size={24} color="white" />
)} )}
DownloadedIconComponent={() => ( 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 // GenreTags.tsx
import type React from "react"; import React from "react";
import { import {StyleProp, TextStyle, View, ViewProps} from "react-native";
type StyleProp,
type TextStyle,
View,
type ViewProps,
} from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface TagProps { interface TagProps {
tags?: string[]; tags?: string[];
textClass?: ViewProps["className"]; textClass?: ViewProps["className"]
} }
export const Tag: React.FC< export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
{ text,
text: string; textClass,
textClass?: ViewProps["className"]; textStyle,
textStyle?: StyleProp<TextStyle>; ...props
} & ViewProps }) => {
> = ({ text, textClass, textStyle, ...props }) => {
return ( return (
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}> <View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
<Text className={textClass} style={textStyle}> <Text className={textClass} style={textStyle}>{text}</Text>
{text}
</Text>
</View> </View>
); );
}; };
export const Tags: React.FC< export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
TagProps & { tagProps?: ViewProps } & ViewProps
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
if (!tags || tags.length === 0) return null; if (!tags || tags.length === 0) return null;
return ( return (
<View <View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
{...props}
>
{tags.map((tag, idx) => ( {tags.map((tag, idx) => (
<View key={idx}> <View key={idx}>
<Tag key={idx} textClass={textClass} text={tag} {...tagProps} /> <Tag key={idx} textClass={textClass} text={tag}/>
</View> </View>
))} ))}
</View> </View>
); );
}; };
export const GenreTags: React.FC<{ genres?: string[] }> = ({ genres }) => { export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
return ( return (
<View className='mt-2'> <View className="mt-2">
<Tags tags={genres} /> <Tags tags={genres}/>
</View> </View>
); );
}; };

View File

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

View File

@@ -1,16 +1,5 @@
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
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 { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem"; import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -19,24 +8,33 @@ import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus"; import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems"; import { SimilarItems } from "@/components/SimilarItems";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries"; import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom } from "@/providers/JellyfinProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites"; 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";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; import { AddToFavorites } from "./AddToFavorites";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -45,20 +43,13 @@ export type SelectedOptions = {
subtitleIndex: number; subtitleIndex: number;
}; };
interface ItemContentProps { export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
item: BaseItemDto; ({ item }) => {
isOffline: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
useImageColors({ item }); useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true); const [loadingLogo, setLoadingLogo] = useState(true);
@@ -73,16 +64,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
defaultBitrate, defaultBitrate,
defaultMediaSource, defaultMediaSource,
defaultSubtitleIndex, defaultSubtitleIndex,
} = useDefaultPlaySettings(item!, settings); } = useDefaultPlaySettings(item, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
@@ -99,50 +81,47 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
defaultMediaSource, defaultMediaSource,
]); ]);
useEffect(() => { if (!Platform.isTV) {
if (!Platform.isTV) { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && ( item && (
<View className='flex flex-row items-center space-x-2'> <View className="flex flex-row items-center space-x-2">
<Chromecast.Chromecast <Chromecast.Chromecast
background='blur' background="blur"
width={22} width={22}
height={22} height={22}
/> />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'> <View className="flex flex-row items-center space-x-2">
{!Platform.isTV && ( <DownloadSingleItem item={item} size="large" />
<DownloadSingleItem item={item} size='large' /> <PlayedStatus items={[item]} size="large" />
)} <AddToFavorites item={item} type="item" />
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View> </View>
)} )}
</View> </View>
), ),
}); });
} }, [item]);
}, [item, navigation, user]); }
useEffect(() => { useEffect(() => {
if (item) { if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) setHeaderHeight(230);
setHeaderHeight(230); else if (item.Type === "Movie") setHeaderHeight(500);
else if (item.Type === "Movie") setHeaderHeight(500); else setHeaderHeight(350);
else setHeaderHeight(350); }, [item.Type, orientation]);
}
}, [item, orientation]);
if (!item || !selectedOptions) return null; const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return null;
return ( return (
<View <View
className='flex-1 relative' className="flex-1 relative"
style={{ style={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
@@ -166,40 +145,41 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View> </View>
} }
logo={ logo={
logoUrl ? ( <>
<Image {logoUrl ? (
source={{ <Image
uri: logoUrl, source={{
}} uri: logoUrl,
style={{ }}
height: 130, style={{
width: "100%", height: 130,
resizeMode: "contain", width: "100%",
}} resizeMode: "contain",
onLoad={() => setLoadingLogo(false)} }}
onError={() => setLoadingLogo(false)} onLoad={() => setLoadingLogo(false)}
/> onError={() => setLoadingLogo(false)}
) : ( />
<View /> ) : null}
) </>
} }
> >
<View className='flex flex-col bg-transparent shrink'> <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'> {/* {!Platform.isTV && ( */}
<ItemHeader item={item} className='mb-4' /> <View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
{item.Type !== "Program" && !Platform.isTV && !isOffline && ( <ItemHeader item={item} className="mb-4" />
<View className='flex flex-row items-center justify-start w-full h-16'> {item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector <BitrateSelector
className='mr-1' className="mr-1"
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val }, (prev) => prev && { ...prev, bitrate: val }
) )
} }
selected={selectedOptions.bitrate} selected={selectedOptions.bitrate}
/> />
<MediaSourceSelector <MediaSourceSelector
className='mr-1' className="mr-1"
item={item} item={item}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
@@ -207,13 +187,13 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
prev && { prev && {
...prev, ...prev,
mediaSource: val, mediaSource: val,
}, }
) )
} }
selected={selectedOptions.mediaSource} selected={selectedOptions.mediaSource}
/> />
<AudioTrackSelector <AudioTrackSelector
className='mr-1' className="mr-1"
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
setSelectedOptions( setSelectedOptions(
@@ -221,7 +201,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
prev && { prev && {
...prev, ...prev,
audioIndex: val, audioIndex: val,
}, }
); );
}} }}
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
@@ -234,7 +214,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
prev && { prev && {
...prev, ...prev,
subtitleIndex: val, subtitleIndex: val,
}, }
) )
} }
selected={selectedOptions.subtitleIndex} selected={selectedOptions.subtitleIndex}
@@ -242,56 +222,49 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View> </View>
)} )}
{/* {!Platform.isTV && ( */}
<PlayButton <PlayButton
className='grow' className="grow"
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
/> />
{/* )} */}
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel <SeasonEpisodesCarousel item={item} loading={loading} />
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
{!isOffline && ( <ItemTechnicalDetails source={selectedOptions.mediaSource} />
<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 !== "Program" && (
<> <>
{item.Type === "Episode" && !isOffline && ( {item.Type === "Episode" && (
<CurrentSeries item={item} className='mb-4' /> <CurrentSeries item={item} className="mb-4" />
)} )}
{!isOffline && ( <CastAndCrew item={item} className="mb-4" loading={loading} />
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && !isOffline && ( {item.People && item.People.length > 0 && (
<View className='mb-4'> <View className="mb-4">
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor <MoreMoviesWithActor
currentItem={item} currentItem={item}
key={idx} key={idx}
actorId={person.Id!} actorId={person.Id!}
className='mb-4' className="mb-4"
/> />
))} ))}
</View> </View>
)} )}
{!isOffline && <SimilarItems itemId={item.Id} />} <SimilarItems itemId={item.Id} />
</> </>
)} )}
</View> </View>
</ParallaxScrollView> </ParallaxScrollView>
</View> </View>
); );
}, }
); );

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import React from "react";
import { View, type ViewProps } from "react-native"; import { View, ViewProps } from "react-native";
import { GenreTags } from "./GenreTags"; import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader"; import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings"; import { Ratings } from "./Ratings";
@@ -15,34 +15,34 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item) if (!item)
return ( return (
<View <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} {...props}
> >
<View className='w-1/3 h-6 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-8 bg-neutral-900 rounded" />
<View className='w-2/3 h-4 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/4 h-4 bg-neutral-900 rounded" />
</View> </View>
); );
return ( return (
<View className='flex flex-col' {...props}> <View className="flex flex-col" {...props}>
<View className='flex flex-col' {...props}> <View className="flex flex-col" {...props}>
<View className='flex flex-row items-center justify-between'> <View className="flex flex-row items-center justify-between">
<Ratings item={item} className='mb-2' /> <Ratings item={item} className="mb-2" />
<ItemActions item={item} /> <ItemActions item={item} />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<View> <>
<EpisodeTitleHeader item={item} /> <EpisodeTitleHeader item={item} />
<GenreTags genres={item.Genres!} /> <GenreTags genres={item.Genres!} />
</View> </>
)} )}
{item.Type === "Movie" && ( {item.Type === "Movie" && (
<View> <>
<MoviesTitleHeader item={item} /> <MoviesTitleHeader item={item} />
<GenreTags genres={item.Genres!} /> <GenreTags genres={item.Genres!} />
</View> </>
)} )}
</View> </View>
</View> </View>

View File

@@ -1,38 +1,38 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo, MediaSourceInfo,
MediaStream, type MediaStream,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import type React from "react"; import React, { useMemo, useRef } from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { formatBitrate } from "@/utils/bitrate";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
import { Text } from "./common/Text"; 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 { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
} }
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => { export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View className='px-4 mt-2 mb-4'> <View className="px-4 mt-2 mb-4">
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text> <Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}> <TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className='flex flex-row space-x-2'> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text> <Text className="text-purple-600">{t("item_card.more_details")}</Text>
</TouchableOpacity> </TouchableOpacity>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
@@ -52,37 +52,31 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
)} )}
> >
<BottomSheetScrollView> <BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4 mb-4'> <View className="flex flex-col space-y-2 p-4 mb-4">
<View> <View className="">
<Text className='text-lg font-bold mb-4'> <Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
{t("item_card.video")} <View className="flex flex-row space-x-2">
</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
</View> </View>
<View> <View className="">
<Text className='text-lg font-bold mb-2'> <Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
{t("item_card.audio")}
</Text>
<AudioStreamInfo <AudioStreamInfo
audioStreams={ audioStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio", (stream) => stream.Type === "Audio"
) || [] ) || []
} }
/> />
</View> </View>
<View> <View className="">
<Text className='text-lg font-bold mb-2'> <Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
{t("item_card.subtitles")}
</Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle", (stream) => stream.Type === "Subtitle"
) || [] ) || []
} }
/> />
@@ -100,25 +94,25 @@ const SubtitleStreamInfo = ({
subtitleStreams: MediaStream[]; subtitleStreams: MediaStream[];
}) => { }) => {
return ( return (
<View className='flex flex-col'> <View className="flex flex-col">
{subtitleStreams.map((stream, _index) => ( {subtitleStreams.map((stream, index) => (
<View key={stream.Index} className='flex flex-col'> <View key={stream.Index} className="flex flex-col">
<Text className='text-xs mb-3 text-neutral-400'> <Text className="text-xs mb-3 text-neutral-400">
{stream.DisplayTitle} {stream.DisplayTitle}
</Text> </Text>
<View className='flex flex-row flex-wrap gap-2'> <View className="flex flex-row flex-wrap gap-2">
<Badge <Badge
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Ionicons name='language-outline' size={16} color='white' /> <Ionicons name="language-outline" size={16} color="white" />
} }
text={stream.Language} text={stream.Language}
/> />
<Badge <Badge
variant='gray' variant="gray"
text={stream.Codec} text={stream.Codec}
iconLeft={ iconLeft={
<Ionicons name='layers-outline' size={16} color='white' /> <Ionicons name="layers-outline" size={16} color="white" />
} }
/> />
</View> </View>
@@ -130,40 +124,40 @@ const SubtitleStreamInfo = ({
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => { const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return ( return (
<View className='flex flex-col'> <View className="flex flex-col">
{audioStreams.map((audioStreams, index) => ( {audioStreams.map((audioStreams, index) => (
<View key={index} className='flex flex-col'> <View key={index} className="flex flex-col">
<Text className='mb-3 text-neutral-400 text-xs'> <Text className="mb-3 text-neutral-400 text-xs">
{audioStreams.DisplayTitle} {audioStreams.DisplayTitle}
</Text> </Text>
<View className='flex-row flex-wrap gap-2'> <View className="flex-row flex-wrap gap-2">
<Badge <Badge
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Ionicons name='language-outline' size={16} color='white' /> <Ionicons name="language-outline" size={16} color="white" />
} }
text={audioStreams.Language} text={audioStreams.Language}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Ionicons <Ionicons
name='musical-notes-outline' name="musical-notes-outline"
size={16} size={16}
color='white' color="white"
/> />
} }
text={audioStreams.Codec} text={audioStreams.Codec}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />} iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
text={audioStreams.ChannelLayout} text={audioStreams.ChannelLayout}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Ionicons name='speedometer-outline' size={16} color='white' /> <Ionicons name="speedometer-outline" size={16} color="white" />
} }
text={formatBitrate(audioStreams.BitRate)} text={formatBitrate(audioStreams.BitRate)}
/> />
@@ -175,55 +169,53 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
}; };
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => { const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
const videoStream = useMemo(() => { if (!source) return null;
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
| MediaStream
| undefined;
}, [source?.MediaStreams]);
if (!source || !videoStream) return null; const videoStream = useMemo(() => {
return source.MediaStreams?.find(
(stream) => stream.Type === "Video"
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return ( return (
<View className='flex-row flex-wrap gap-2'> <View className="flex-row flex-wrap gap-2">
<Badge <Badge
variant='gray' variant="gray"
iconLeft={<Ionicons name='film-outline' size={16} color='white' />} iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={formatFileSize(source.Size)} text={formatFileSize(source.Size)}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={<Ionicons name='film-outline' size={16} color='white' />} iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={`${videoStream.Width}x${videoStream.Height}`} text={`${videoStream.Width}x${videoStream.Height}`}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Ionicons name='color-palette-outline' size={16} color='white' /> <Ionicons name="color-palette-outline" size={16} color="white" />
} }
text={videoStream.VideoRange} text={videoStream.VideoRange}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Ionicons name='code-working-outline' size={16} color='white' /> <Ionicons name="code-working-outline" size={16} color="white" />
} }
text={videoStream.Codec} text={videoStream.Codec}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Ionicons name='speedometer-outline' size={16} color='white' /> <Ionicons name="speedometer-outline" size={16} color="white" />
} }
text={formatBitrate(videoStream.BitRate)} text={formatBitrate(videoStream.BitRate)}
/> />
<Badge <Badge
variant='gray' variant="gray"
iconLeft={<Ionicons name='play-outline' size={16} color='white' />} iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
text={ text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
videoStream.AverageFrameRate != null
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
: ""
}
/> />
</View> </View>
); );
@@ -234,9 +226,15 @@ const formatFileSize = (bytes?: number | null) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte"; if (bytes === 0) return "0 Byte";
const i = Number.parseInt( const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
Math.floor(Math.log(bytes) / Math.log(1024)).toString(), return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
10, };
);
return `${Math.round((bytes / 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 type React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { View, Text, TouchableOpacity } from "react-native";
import { Text, View } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery"; import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import { Button } from "./Button"; import { Button } from "./Button";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
onServerSelect?: (server: { address: string; serverName?: string }) => void; onServerSelect?: (server: { address: string; serverName?: string }) => void;
@@ -15,17 +15,15 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View className='mt-2'> <View className="mt-2">
<Button onPress={startDiscovery} color='black'> <Button onPress={startDiscovery} color="black">
<Text className='text-white text-center'> <Text className="text-white text-center">
{isSearching {isSearching ? t("server.searching") : t("server.search_for_local_servers")}
? t("server.searching")
: t("server.search_for_local_servers")}
</Text> </Text>
</Button> </Button>
{servers.length ? ( {servers.length ? (
<ListGroup title={t("server.servers")} className='mt-4'> <ListGroup title={t("server.servers")} className="mt-4">
{servers.map((server) => ( {servers.map((server) => (
<ListItem <ListItem
key={server.address} key={server.address}

View File

@@ -1,7 +1,8 @@
import { import {
ActivityIndicator, ActivityIndicator,
type ActivityIndicatorProps, ActivityIndicatorProps,
Platform, Platform,
View,
} from "react-native"; } from "react-native";
interface Props extends ActivityIndicatorProps {} interface Props extends ActivityIndicatorProps {}

View File

@@ -1,14 +1,12 @@
import type { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -22,54 +20,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
const isTv = Platform.isTV; if (Platform.isTV) return null;
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item, selected]
);
const { t } = useTranslation(); const { t } = useTranslation();
const getDisplayName = useCallback((source: MediaSourceInfo) => { const commonPrefix = useMemo(() => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); const mediaSources = item.MediaSources || [];
if (videoStream?.DisplayTitle) { if (!mediaSources.length) return "";
return videoStream.DisplayTitle;
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
const char = mediaSources[0].Name![i];
if (mediaSources.every((source) => source.Name![i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
} }
return commonPrefix;
}, [item.MediaSources]);
// Fallback to source name const name = (name?: string | null) => {
if (source.Name) { return name?.replace(commonPrefix, "").toLowerCase();
return source.Name; };
}
// Last resort fallback
return `Source ${source.Id}`;
}, []);
const selectedName = useMemo(() => {
if (!selected) return "";
return getDisplayName(selected);
}, [selected, getDisplayName]);
if (isTv) return null;
return ( return (
<View <View
className='flex shrink' className="flex shrink"
style={{ style={{
minWidth: 50, minWidth: 50,
}} }}
> >
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className='flex flex-col' {...props}> <View className="flex flex-col" {...props}>
<Text className='opacity-50 mb-1 text-xs'> <Text className="opacity-50 mb-1 text-xs">
{t("item_card.video")} {t("item_card.video")}
</Text> </Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={true} loop={true}
side='bottom' side="bottom"
align='start' align="start"
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={8} collisionPadding={8}
@@ -84,7 +88,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{getDisplayName(source)} {`${name(source.Name)}`}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
))} ))}

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